@ghostlytools/cli 1.3.9 → 1.4.0

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.
Files changed (98) hide show
  1. package/README.md +13 -4
  2. package/SKILL.md +7 -4
  3. package/dist/assets/{api-Cp9GxjUO.js → api-BqLfti32.js} +2 -2
  4. package/dist/assets/{api-BCrwJ57L.js → api-JMkEO0RR.js} +130 -50
  5. package/dist/assets/api-JMkEO0RR.js.map +1 -0
  6. package/dist/assets/{apiKit-B4qhnLQT.js → apiKit-BpIUCwa-.js} +3 -3
  7. package/dist/assets/{apiKit-B4qhnLQT.js.map → apiKit-BpIUCwa-.js.map} +1 -1
  8. package/dist/assets/{apps-CpxENMgl.js → apps-BdfyGVZ1.js} +1 -1
  9. package/dist/assets/{apps-DOH1P_S0.js → apps-Bettj7WQ.js} +6 -6
  10. package/dist/assets/apps-Bettj7WQ.js.map +1 -0
  11. package/dist/assets/{arkade-Cyf5MGxG.js → arkade-CP4HvKaa.js} +2 -2
  12. package/dist/assets/{arkade-Cyf5MGxG.js.map → arkade-CP4HvKaa.js.map} +1 -1
  13. package/dist/assets/{backup-CqM-0-2v.js → backup-BbO2zLjx.js} +1 -1
  14. package/dist/assets/{backup-CTpFFQ-4.js → backup-BmBtta66.js} +7 -7
  15. package/dist/assets/{backup-CTpFFQ-4.js.map → backup-BmBtta66.js.map} +1 -1
  16. package/dist/assets/blobFiles-B0rNnHlO.js +35 -0
  17. package/dist/assets/blobFiles-B0rNnHlO.js.map +1 -0
  18. package/dist/assets/{buttons-BtxwyM2l.js → buttons-YgijrprJ.js} +4 -4
  19. package/dist/assets/{buttons-BtxwyM2l.js.map → buttons-YgijrprJ.js.map} +1 -1
  20. package/dist/assets/buttons-_GBlxAti.js +2 -0
  21. package/dist/assets/{endpoint-BOTFMpfS.js → endpoint-Cl3cgAYl.js} +3 -2
  22. package/dist/assets/endpoint-Cl3cgAYl.js.map +1 -0
  23. package/dist/assets/{envelope-DW8TWKPD.js → envelope-C7vnmsJN.js} +2 -2
  24. package/dist/assets/{envelope-DW8TWKPD.js.map → envelope-C7vnmsJN.js.map} +1 -1
  25. package/dist/assets/{errors-B-zMxbcd.js → errors-BLSPp0tK.js} +3 -1
  26. package/dist/assets/errors-BLSPp0tK.js.map +1 -0
  27. package/dist/assets/{fileBytes-Di7VZtmU.js → fileBytes-B-fqGdEd.js} +3 -3
  28. package/dist/assets/{fileBytes-Di7VZtmU.js.map → fileBytes-B-fqGdEd.js.map} +1 -1
  29. package/dist/assets/{fileBytes-CgbibpbV.js → fileBytes-C2jycN_7.js} +1 -1
  30. package/dist/assets/{fileBytes-CfSXjTFa.js → fileBytes-D6fqFQEX.js} +6 -3
  31. package/dist/assets/fileBytes-D6fqFQEX.js.map +1 -0
  32. package/dist/assets/fileBytesIdb-DOxm_BKt.js +2 -0
  33. package/dist/assets/{fileBytesIdb-D5u6H8X_.js → fileBytesIdb-_HnxdacI.js} +3 -3
  34. package/dist/assets/{fileBytesIdb-D5u6H8X_.js.map → fileBytesIdb-_HnxdacI.js.map} +1 -1
  35. package/dist/assets/{fileBytesOpfs-Dd1N4ERH.js → fileBytesOpfs-p_CANncD.js} +3 -3
  36. package/dist/assets/{fileBytesOpfs-Dd1N4ERH.js.map → fileBytesOpfs-p_CANncD.js.map} +1 -1
  37. package/dist/assets/{groupFiles-uxa0U9_n.js → groupFiles-sIdGq4AV.js} +5 -5
  38. package/dist/assets/groupFiles-sIdGq4AV.js.map +1 -0
  39. package/dist/assets/{host-DPy0eaH9.js → host-Drd-c4VO.js} +147 -25
  40. package/dist/assets/host-Drd-c4VO.js.map +1 -0
  41. package/dist/assets/{hyperdhtRelay-De5FT7Am.js → hyperdhtRelay-13aQ4KOf.js} +2 -2
  42. package/dist/assets/{hyperdhtRelay-De5FT7Am.js.map → hyperdhtRelay-13aQ4KOf.js.map} +1 -1
  43. package/dist/assets/{idb-B5L8wuOm.js → idb-CMVgtn2k.js} +59 -6
  44. package/dist/assets/idb-CMVgtn2k.js.map +1 -0
  45. package/dist/assets/{irohWeb-z5QDDWa-.js → irohWeb-DHCxSvHF.js} +2 -2
  46. package/dist/assets/{irohWeb-z5QDDWa-.js.map → irohWeb-DHCxSvHF.js.map} +1 -1
  47. package/dist/assets/{main-Dt7xrqtb.js → main-BWAwKY4f.js} +476 -141
  48. package/dist/assets/main-BWAwKY4f.js.map +1 -0
  49. package/dist/assets/{mainline-Cld-bKeC.js → mainline-CsG-DdJ5.js} +2 -2
  50. package/dist/assets/{mainline-Cld-bKeC.js.map → mainline-CsG-DdJ5.js.map} +1 -1
  51. package/dist/assets/{networkReason-vUHo6A_C.js → networkReason-B91upMCb.js} +3 -3
  52. package/dist/assets/{networkReason-vUHo6A_C.js.map → networkReason-B91upMCb.js.map} +1 -1
  53. package/dist/assets/{nwc-zXpmd2FN.js → nwc-BFv67iSz.js} +3 -3
  54. package/dist/assets/{nwc-zXpmd2FN.js.map → nwc-BFv67iSz.js.map} +1 -1
  55. package/dist/assets/{nwcProvider-D482SQ1a.js → nwcProvider-BKNFdH9v.js} +2 -2
  56. package/dist/assets/{nwcProvider-D482SQ1a.js.map → nwcProvider-BKNFdH9v.js.map} +1 -1
  57. package/dist/assets/{profiles-gPCjLOQq.js → profiles-DSxPYdlU.js} +13 -3
  58. package/dist/assets/profiles-DSxPYdlU.js.map +1 -0
  59. package/dist/assets/{registry-CxsDR3c6.js → registry-CHPd_hf7.js} +1 -1
  60. package/dist/assets/{registry-OVx2kY3d.js → registry-qbnWmwCE.js} +3 -3
  61. package/dist/assets/registry-qbnWmwCE.js.map +1 -0
  62. package/dist/assets/{resolve-CPTAnu-9.js → resolve-B63_uSu7.js} +2 -2
  63. package/dist/assets/{resolve-CPTAnu-9.js.map → resolve-B63_uSu7.js.map} +1 -1
  64. package/dist/assets/{restoredRows-Db3oUTko.js → restoredRows-DeBImGRd.js} +2 -2
  65. package/dist/assets/{restoredRows-Db3oUTko.js.map → restoredRows-DeBImGRd.js.map} +1 -1
  66. package/dist/assets/{server-p5DJnN7T.js → server-Cd1dLOB8.js} +1485 -240
  67. package/dist/assets/server-Cd1dLOB8.js.map +1 -0
  68. package/dist/assets/{src-Dg1bvMNv.js → src-DzXL-GYu.js} +7277 -5975
  69. package/dist/assets/src-DzXL-GYu.js.map +1 -0
  70. package/dist/assets/turnRefs-BCtM10d3.js +357 -0
  71. package/dist/assets/turnRefs-BCtM10d3.js.map +1 -0
  72. package/dist/assets/{usdt-BY5OIIvG.js → usdt-B7JWDhQV.js} +3 -3
  73. package/dist/assets/{usdt-BY5OIIvG.js.map → usdt-B7JWDhQV.js.map} +1 -1
  74. package/dist/ghostly.mjs +1 -1
  75. package/package.json +7 -6
  76. package/skills/ghostly/README.md +41 -0
  77. package/skills/ghostly/SKILL.md +79 -0
  78. package/skills/ghostly/scripts/listen +67 -0
  79. package/skills/ghostly/scripts/react +25 -0
  80. package/skills/ghostly/scripts/say +44 -0
  81. package/skills/ghostly/scripts/status +27 -0
  82. package/dist/assets/api-BCrwJ57L.js.map +0 -1
  83. package/dist/assets/apps-DOH1P_S0.js.map +0 -1
  84. package/dist/assets/buttons-CJqNkG-Q.js +0 -2
  85. package/dist/assets/endpoint-BOTFMpfS.js.map +0 -1
  86. package/dist/assets/errors-B-zMxbcd.js.map +0 -1
  87. package/dist/assets/fileBytes-CfSXjTFa.js.map +0 -1
  88. package/dist/assets/fileBytesIdb-CFP-DSkZ.js +0 -2
  89. package/dist/assets/groupFiles-uxa0U9_n.js.map +0 -1
  90. package/dist/assets/host-DPy0eaH9.js.map +0 -1
  91. package/dist/assets/idb-B5L8wuOm.js.map +0 -1
  92. package/dist/assets/main-Dt7xrqtb.js.map +0 -1
  93. package/dist/assets/profiles-gPCjLOQq.js.map +0 -1
  94. package/dist/assets/registry-OVx2kY3d.js.map +0 -1
  95. package/dist/assets/server-p5DJnN7T.js.map +0 -1
  96. package/dist/assets/src-Dg1bvMNv.js.map +0 -1
  97. package/dist/assets/turnRefs-WnggXm2T.js +0 -323
  98. package/dist/assets/turnRefs-WnggXm2T.js.map +0 -1
@@ -1,27 +1,26 @@
1
1
  import { n as traceJoin } from "./joinTrace-CjRDFy85.js";
2
- import { $c as currentRelays, $i as revokesIdentity, $n as GROUP_FILE_LIMITS, $r as WAKE_CALL_INTERVAL_MS, $t as didDhtFromPublicKey, A as CommunitySession, Aa as forwardedAgain, Ac as devicePingFrame, Ao as rosterAdmin, Ar as newerHead, As as handoffOfferFrame, B as knockRecords, Ba as readReaction, Bc as TURN_MAX, Bi as capsDescriptors, Bn as findEndpoint, Bo as GhostlyHttpError, Br as transferStalled, Bs as readHandoffHave, C as sha256Paced, Ca as parseImageMeta, Cc as DEVICES_CAPABILITY, Ci as readManifest, Cl as randomBytes, Cn as isBitcoinAddress, Cr as beaconRecords, Cs as handoffContext, D as openGroupTyping, Da as RateWindow, Dc as deviceEchoNonce, Dl as utf8Encode, Do as MEMBER_KEY, Dr as lobbyRecords, Ds as handoffEphemeral, E as groupTypingEpoch, Ea as EDIT_RATE_WINDOW_MS, Ec as deviceEchoFrame, El as utf8Decode, Er as lobbyKeys, Es as handoffDigest, Et as normalizeAtprotoHandle, Fa as ReactionWindow, Fc as TURN_RAISE_READ_TIMEOUT_MS, Fr as readLobby, Fs as handoffVersions, G as groupWakeFrame, Ga as replySnippet, Gc as signTurnRelease, Gn as INVITE_TAKEN, Go as decodeControl, Gr as typingActivity, Gs as sealHandoffPiece, H as readKnocks, Ha as wireReaction, Hc as nextTurnPosition, Hn as parseCashuRequestPayload, Ho as parseLocalTarget, Hs as readHandoffPake, I as decodeGroupEntryLink, Ia as nextReactionNumber, Ic as TURN_SETTLE_MS, In as decodeBolt11, Io as DHT_POLL_INTERVALS, Ir as shouldBeHub, Is as newHandoffId, J as GROUP_LIMITS, Jc as turnName, Ji as emptyIdentityLedger, Jn as pairedMessageFits, Jo as RTC_CONFIG, Jr as mayPin, Js as readSetUpdate, K as groupWakes, Ka as BUTTON_ID, Kc as turnInstance, Ki as IdentityExchange, Kn as LIVENESS_PING_MS, Ko as encodeControl, L as encodeGroupEntryLink, La as queueReaction, Lc as classifyTurnRead, Ln as ENDPOINT, Lr as ChatFiles, Ls as readHandoffAck, M as communityHasFrame, Ma as COMMUNITY_REACTION_FRAME, Mc as seedSigner, Mo as verifyCommitSignature, Mr as rankHubs, Ms as handoffReleaseFrame, N as decodeCommunityLink, Na as GROUP_REACTION_FRAME, Nc as webCryptoSigner, No as edgeParams, Nr as readBeacon, Ns as handoffRetryAfter, O as sealGroupTyping, Oa as dhtEditId, Oc as deviceKeyZ32, Oo as commitHash, Or as mergeBeacon, Os as handoffHelloFrame, P as encodeCommunityLink, Pa as REACTION_LIMITS, Po as DirectPathWatch, Pr as readBeaconHead, Ps as handoffStreamKey, Q as frameBytes, Qc as RelayTransport, Qi as receivedIdentityStatus, Qn as parseEngineError, Qr as readPin, Qs as verifySetUpdate, Qt as didDhtDocument, R as entryParams, Ra as reactionEmoji, Rc as turnPutSummary, Ri as CapsExchange, Rn as cashuRequestPayload, Ro as RELAY_POLL_INTERVALS, Rr as FILE_LIMITS, Rs as readHandoffCancel, S as readAppBundleAsync, Sa as parseVideoMeta, Si as newMailbox, Sn as sparkInvoiceDetails, So as canonicalJson, Sr as beaconKeys, Ss as handoffCancelFrame, T as GroupTypingBoard, Ta as EDIT_BUFFER_MS, Tc as HANDOFF_CAPABILITY, Tl as toZ32, Tn as isPubkyKey, To as GROUP_MEMBER_CAP, Tr as freshHubs, Ts as handoffDataFrame, Tt as isAtprotoDid, Ua as pairedReplyAuthor, Uc as readTurnPacket, Un as parseFedimintRequestPayload, Uo as webLocalFetch, Ur as TypingSender, Us as readHandoffTurnFrame, V as mergeKnocks, Va as validReactionNumber, Vc as firstDeviceSetSecret, Vi as dialDescriptors, Vn as isFederationId, Vo as formatLocalTarget, Vs as readHandoffHello, W as GroupWakeLimiter, Wa as readReply, Wc as signTurnPacket, Wi as IDENTITY_REVOCATION_LABEL, Wn as GhostLink, Wo as LIMITS, Ws as readHandoffVerified, X as GROUP_READ_NOTE, Xc as turnVerifierMark, Xi as identityStatement, Xn as engineError, Xr as pinIsNewer, Xs as setUpdateFrame, Yc as turnStatedVerifier, Yi as identityRevocationValue, Yn as pairedMessageFrame, Yr as nextPinNumber, Ys as setAckFrame, Z as GroupSession, Za as STATUS_CARD_LIMITS, Zc as DEFAULT_RELAYS, Zi as newIdentityBinding, Zn as engineText, Zr as pinNumberHolds, Zs as signTombstone, _c as createChatInvite, _i as HOLD_DATED_BACK_MS, _n as validatePaymentTarget, _o as isAppRef, _r as MESH_HUBS, _s as filePartName, a as planAppUpdate, aa as automaticTransport, ac as enrollDigits, ai as wakeRequest, al as withRequestOptions, an as EVM_TEST_CHAINS, ao as arrivalKey, as as serviceIdFromName, at as groupName, b as pacer, ba as parseVoiceMeta, bi as HoldRefusedError, bl as bytesEqual, bn as SPARK_PROVIDER, br as pickMeshHubs, bs as handoffAttemptSucceeded, cc as enrollHelloFrame, cl as createRelayPayload, cn as SEPOLIA_TEST_USDT, co as receivedTimestamp, cs as HANDOFF_DONE, d as APP_BUNDLE_LIMITS, da as DHT_FILE_CAPABILITY, dc as readEnrollHello, di as APPS_CAPABILITY, dn as USDT_PUBLIC_RPC, do as APP_STATEMENT_LIMITS, ds as HANDOFF_OFFER, ea as lastSharedWithMe, ei as WakeLimiter, el as normalizeRelayUrl, en as encodeDidDhtDeactivated, es as parseCallSignal, f as APP_BUNDLE_MAGIC, fa as dhtFileId, fc as verifyEnrollProof, fi as BUTTONS_CAPABILITY, fl as createIdentity, fn as WALLET_NETWORKS, fo as appFingerprint, fs as HANDOFF_PAKE, g as appViewOf, gc as OwnInviteError, gi as HOLD_CAPABILITY, gl as verify, gn as formatPaymentAmount, go as isAppKey, gr as readGroupSignal, gs as HANDOFF_VERIFIED, h as appDigest, ha as sanitizeMime, hc as readDeviceInvite, hi as UPGRADE_CAPABILITY, hl as publicKeyFromZ32, hn as assertWholeSats, ho as isAppHash, hr as groupSignalFrame, hs as HANDOFF_TIMINGS, i as checkAppBeforeRun, ia as TRANSPORTS, ic as enrollCancelReason, ii as relayRequest, il as isDiscoveryBudgetError, in as ETHEREUM_USDT, io as statusCardText, ir as groupFileFallback, is as LEGACY_SERVICES, it as mentionsMember, j as GROUP_READ_NOTE_COMMUNITY, ja as readForwarded, jc as newDeviceSetSecret, jo as rosterHas, jr as pickHubs, js as handoffPakeFrame, k as COMMUNITY_LIMITS, ka as parseLinkPreview, kc as deviceLinkPairing, ko as parseGroupCommit, kr as mergeLobby, ks as handoffManifestFrames, l as readAppStoreSteps, la as DHT_MESSAGE_TTL, lc as enrollProofFrame, li as chatAppId, lo as shownTime, ls as HANDOFF_HAVE, ma as sanitizeFileName, mc as deviceInviteJoinerParams, mi as STATUS_CARD_CAPABILITY, ml as identityFromSeedB64, mn as assertTokenUnits, ms as HANDOFF_REQUEST, n as appStoreDecision, na as emptyProofLedger, ni as newWakeToken, nl as traceLink, no as checkStatusCard, ns as emptyLinkRecords, nt as carryMentions, o as APP_STORE_LIMITS, oc as enrollDoneFrame, oi as checkPushEndpoint, ol as PacketTooLargeError, on as ONCHAIN_PROVIDER, oo as claimedTime, or as readGroupFileMeta, os as HANDOFF_ACK, p as APP_ICON_PATH, pa as formatFileSize, pc as createDeviceInvite, pi as EDIT_CAPABILITY, pl as identityFromSeed, pn as arkAddressPrefix, po as appRef, ps as HANDOFF_PIECE_BYTES, q as parseGroupWakeFrame, qc as turnKeys, qn as PAIRED_FRAME_TOO_LARGE, qr as PIN_LIMITS, r as appUpdateDecision, ra as validExternalKey, rc as enrollCancelFrame, ri as parseWakeFrame, rl as budgetRetryMs, rn as signDidDhtPacket, ro as sameButtonText, rs as HTTP_SERVICE_PROTO, rt as parseCommunityEdit, sc as enrollGrantFrame, si as vapidKeysMatch, sn as PaymentPreflightError, so as heardTime, ss as HANDOFF_CANCEL, t as appRevocationFor, ta as PeerProofs, ti as deviceWakeRequest, tn as encodeDidDhtPacket, tt as GROUP_EDIT_TEXT_BYTES, ua as peekDhtMailbox, uc as readEnrollGrant, ui as isAppVersion, un as TEST_USDT_FAUCET_AMOUNT, ur as CarriedTransport, us as HANDOFF_HELLO, vc as decodeInviteCode, vi as HOLD_LIMITS, vl as sanitizeAvatar, vn as walletNetworkOf, vo as readAppRevocation, vr as mayBeHub, vs as handoffAttemptAllowed, wc as ENROLL_CAPABILITY, wl as toBase64Url, wo as readCanonicalJson, wr as doorHubs, ws as handoffContextProof, xc as inviteOwnership, xi as isHoldUrl, xl as fromBase64Url, xn as sparkAddressKind, xr as COMMUNITY_TOPOLOGY, xs as handoffBusyFrame, y as isAppUrl, ya as fileMessageText, yc as encodeInviteCode, yi as HoldKeys, yl as sanitizeDisplayText, yn as SPARK_NETWORKS, yr as meshHubs, ys as handoffAttemptFailed, z as knockIdentity, za as reactionIsNewer, zc as TURN_LAST_SEQUENCE, zi as DHT_TEXT_CAPABILITY, zn as fedimintRequestPayload, zo as presenceSeenAt, zr as transferEnded, zs as readHandoffDone } from "./src-Dg1bvMNv.js";
2
+ import { $a as GROUP_MEMBER_CAP, $c as DEVICES_CAPABILITY, $i as DHT_MESSAGE_TTL, $l as randomBytes, $o as handoffContextProof, $r as chatAppId, $s as knockSlotIdentity, A as knockRecords, Aa as wireReaction, Ac as isAppRef, Ai as capsDescriptors, Al as normalizeRelayUrl, An as findEndpoint, Ar as transferStalled, B as GROUP_READ_NOTE, Bc as createContactLink, Bi as identityStatement, Bn as engineError, Bo as HANDOFF_OFFER, Br as pinIsNewer, Bs as readEnrollHello, C as decodeCommunityLink, Ca as ReactionWindow, Cl as turnKeys, Co as RTC_CONFIG, Cr as readBeacon, Cs as readSetUpdate, D as encodeGroupEntryLink, Da as reactionIsNewer, Dl as DEFAULT_RELAYS, Dn as ENDPOINT, Dr as ChatFiles, Ds as verifySetUpdate, E as decodeGroupEntryLink, Ea as reactionEmoji, Ec as appRef, El as turnVerifierMark, En as decodeBolt11, Er as shouldBeHub, Es as signTombstone, F as groupWakeFrame, Fc as parseImageMeta, Fl as withRequestOptions, Fn as INVITE_TAKEN, Fo as HANDOFF_ACK, Fr as typingActivity, Fs as enrollDoneFrame, G as GROUP_EDIT_TEXT_BYTES, Ga as sameButtonText, Gc as readDeviceInvite, Gi as PeerProofs, Gl as publicKeyFromZ32, Go as HANDOFF_VERIFIED, Gr as deviceWakeRequest, Gs as newInbox, Gt as encodeDidDhtPacket, H as frameBytes, Hc as readContactLink, Hi as receivedIdentityStatus, Hl as createIdentity, Hn as parseEngineError, Ho as HANDOFF_PIECE_BYTES, Hr as readPin, Hs as clearMissed, Ht as didDhtDocument, I as groupWakes, Ii as IdentityExchange, Il as PacketTooLargeError, In as LIVENESS_PING_MS, Io as HANDOFF_CANCEL, Is as enrollGrantFrame, J as mentionsMember, Ja as claimedTime, Jc as decodeInviteCode, Ji as TRANSPORTS, Jl as sanitizeAvatar, Jn as groupFileFallback, Jo as handoffAttemptFailed, Jr as relayRequest, Js as takeRequest, Jt as ETHEREUM_USDT, K as carryMentions, Ka as statusCardText, Kc as OwnInviteError, Ki as emptyProofLedger, Kl as verify, Ko as filePartName, Kr as newWakeToken, Ks as planKnocks, L as parseGroupWakeFrame, Lc as canonicalJson, Ln as PAIRED_FRAME_TOO_LARGE, Lo as HANDOFF_DONE, Lr as PIN_LIMITS, Ls as enrollHelloFrame, M as readKnocks, Ma as readReply, Ml as traceLink, Mn as parseCashuRequestPayload, Mo as HTTP_SERVICE_PROTO, Ms as enrollCancelFrame, Na as replySnippet, Nl as budgetRetryMs, Nn as parseFedimintRequestPayload, No as LEGACY_SERVICES, Nr as TypingSender, Ns as enrollCancelReason, O as entryParams, Oa as readReaction, Oc as isAppHash, Oi as CapsExchange, Ol as RelayTransport, On as cashuRequestPayload, Or as FILE_LIMITS, P as GroupWakeLimiter, Pa as BUTTON_ID, Pi as IDENTITY_REVOCATION_LABEL, Pl as isDiscoveryBudgetError, Pn as GhostLink, Po as serviceIdFromName, Ps as enrollDigits, Qa as GROUP_ADMIN_OFF_ERROR, Qo as handoffContext, Qs as contactDay, Qt as SEPOLIA_TEST_USDT, R as GROUP_LIMITS, Ri as emptyIdentityLedger, Rl as createRelayPayload, Rn as pairedMessageFits, Ro as HANDOFF_HAVE, Rr as mayPin, Rs as enrollProofFrame, S as communityHasFrame, Sa as REACTION_LIMITS, Sc as sha256Paced, Sl as turnInstance, Sr as rankHubs, Ta as queueReaction, Tc as appFingerprint, Tl as turnStatedVerifier, Tr as readLobby, Ts as setUpdateFrame, Uc as createDeviceInvite, Ui as revokesIdentity, Ul as identityFromSeed, Un as GROUP_FILE_LIMITS, Uo as HANDOFF_REQUEST, Ur as WAKE_CALL_INTERVAL_MS, Us as endedRequests, Ut as didDhtFromPublicKey, V as GroupSession, Vc as encodeContactLink, Vi as newIdentityBinding, Vn as engineText, Vo as HANDOFF_PAKE, Vr as pinNumberHolds, Vs as verifyEnrollProof, Wa as checkStatusCard, Wc as deviceInviteJoinerParams, Wi as lastSharedWithMe, Wl as identityFromSeedB64, Wo as HANDOFF_TIMINGS, Wr as WakeLimiter, Ws as inboxRecord, Wt as encodeDidDhtDeactivated, Xa as receivedTimestamp, Xc as inviteLink, Xl as bytesEqual, Xn as readGroupFileMeta, Xo as handoffBusyFrame, Xr as checkPushEndpoint, Xs as ContactRequestError, Xt as ONCHAIN_PROVIDER, Y as groupName, Ya as heardTime, Yc as encodeInviteCode, Yi as automaticTransport, Yl as sanitizeDisplayText, Yo as handoffAttemptSucceeded, Yr as wakeRequest, Ys as turnOff, Yt as EVM_TEST_CHAINS, Za as shownTime, Zc as inviteOwnership, Zl as fromBase64Url, Zo as handoffCancelFrame, Zr as vapidKeysMatch, Zs as REQUEST_LIFETIME_MS, Zt as PaymentPreflightError, _ as openGroupTyping, _a as parseLinkPreview, _l as firstDeviceSetSecret, _o as parseLocalTarget, _r as lobbyRecords, _s as readHandoffPake, a as appStoreDecision, aa as sanitizeMime, ac as openRequest, ai as UPGRADE_CAPABILITY, al as deviceLinkPairing, an as assertWholeSats, ao as rosterHas, ar as groupSignalFrame, as as handoffOfferFrame, b as CommunitySession, ba as COMMUNITY_REACTION_FRAME, bl as signTurnPacket, bo as decodeControl, br as newerHead, bs as sealHandoffPiece, c as planAppUpdate, cc as requestRecords, ci as HOLD_LIMITS, cl as seedSigner, cn as walletNetworkOf, co as DirectPathWatch, cr as mayBeHub, cs as handoffRetryAfter, dc as APP_BUNDLE_MAGIC, di as isHoldUrl, dl as TURN_RAISE_READ_TIMEOUT_MS, dn as sparkAddressKind, dr as COMMUNITY_TOPOLOGY, ds as newHandoffId, ea as peekDhtMailbox, ec as knockSlotOf, ei as isAppVersion, el as ENROLL_CAPABILITY, en as TEST_USDT_FAUCET_AMOUNT, er as CarriedTransport, es as handoffDataFrame, eu as toBase64Url, f as readAppStoreSteps, fa as parseVideoMeta, fc as APP_ICON_PATH, fi as newMailbox, fl as TURN_SETTLE_MS, fn as sparkInvoiceDetails, fo as RELAY_POLL_INTERVALS, fr as beaconKeys, fs as readHandoffAck, g as groupTypingEpoch, ga as dhtEditId, gl as TURN_MAX, go as formatLocalTarget, gr as lobbyKeys, gs as readHandoffHello, gt as normalizeAtprotoHandle, h as GroupTypingBoard, ha as RateWindow, hc as appViewOf, hl as TURN_LAST_SEQUENCE, hn as isPubkyKey, ho as GhostlyHttpError, hr as freshHubs, hs as readHandoffHave, ht as isAtprotoDid, i as appRevocationFor, ia as sanitizeFileName, ic as mergeContactKnocks, ii as STATUS_CARD_CAPABILITY, il as deviceKeyZ32, in as assertTokenUnits, io as rosterAdmin, is as handoffManifestFrames, j as mergeKnocks, ja as pairedReplyAuthor, jc as readAppRevocation, ji as dialDescriptors, jn as isFederationId, jo as emptyLinkRecords, k as knockIdentity, ka as validReactionNumber, kc as isAppKey, ki as DHT_TEXT_CAPABILITY, kl as currentRelays, kn as fedimintRequestPayload, ko as parseCallSignal, kr as transferEnded, l as APP_STORE_LIMITS, la as fileMessageText, lc as seenEntry, li as HoldKeys, ll as webCryptoSigner, ln as SPARK_NETWORKS, lr as meshHubs, ls as handoffStreamKey, ma as EDIT_RATE_WINDOW_MS, mc as appDigest, ml as turnPutSummary, mo as presenceSeenAt, mr as doorHubs, ms as readHandoffDone, n as readX401SdJwtVc, na as dhtFileId, nc as linkRecords, ni as BUTTONS_CAPABILITY, nl as deviceEchoFrame, nn as WALLET_NETWORKS, no as commitHash, ns as handoffEphemeral, nu as utf8Decode, o as appUpdateDecision, oc as readKnockSlot, oi as HOLD_CAPABILITY, ol as devicePingFrame, on as formatPaymentAmount, oo as verifyCommitSignature, or as readGroupSignal, os as handoffPakeFrame, pa as EDIT_BUFFER_MS, pi as readManifest, pl as classifyTurnRead, pn as isBitcoinAddress, po as WATCH_PEER_MS, pr as beaconRecords, ps as readHandoffCancel, q as parseCommunityEdit, qa as arrivalKey, qc as createChatInvite, qi as validExternalKey, qo as handoffAttemptAllowed, qr as parseWakeFrame, qs as settleRequest, qt as signDidDhtPacket, r as sameX401HolderJwk, ra as formatFileSize, rc as makeStampPaced, ri as EDIT_CAPABILITY, rl as deviceEchoNonce, rn as arkAddressPrefix, ro as parseGroupCommit, rs as handoffHelloFrame, ru as utf8Encode, s as checkAppBeforeRun, sc as readLinkRecord, si as HOLD_DATED_BACK_MS, sl as newDeviceSetSecret, sn as validatePaymentTarget, so as edgeParams, sr as MESH_HUBS, ss as handoffReleaseFrame, t as newX401HolderKey, ta as DHT_FILE_CAPABILITY, tc as knockSlotRecords, ti as APPS_CAPABILITY, tl as HANDOFF_CAPABILITY, tn as USDT_PUBLIC_RPC, to as MEMBER_KEY, ts as handoffDigest, tu as toZ32, ua as parseVoiceMeta, uc as APP_BUNDLE_LIMITS, ui as HoldRefusedError, un as SPARK_PROVIDER, uo as DHT_POLL_INTERVALS, ur as pickMeshHubs, us as handoffVersions, v as sealGroupTyping, va as forwardedAgain, vc as isAppUrl, vl as nextTurnPosition, vo as webLocalFetch, vr as mergeBeacon, vs as readHandoffTurnFrame, w as encodeCommunityLink, wa as nextReactionNumber, wc as APP_STATEMENT_LIMITS, wl as turnName, wr as readBeaconHead, ws as setAckFrame, x as GROUP_READ_NOTE_COMMUNITY, xa as GROUP_REACTION_FRAME, xc as readAppBundleAsync, xl as signTurnRelease, xo as encodeControl, xr as pickHubs, y as COMMUNITY_LIMITS, ya as readForwarded, yc as pacer, yl as readTurnPacket, yo as LIMITS, yr as mergeLobby, ys as readHandoffVerified, za as STATUS_CARD_LIMITS, zc as readCanonicalJson, zi as identityRevocationValue, zn as pairedMessageFrame, zo as HANDOFF_HELLO, zr as nextPinNumber, zs as readEnrollGrant } from "./src-DzXL-GYu.js";
3
3
  import { i as SourceUnreachableError, n as PROVIDER_PLATFORMS, o as isNothingSpentError, r as SourceConfigError, t as NothingSpentError, u as redact } from "./types-C4_KafL_.js";
4
4
  import { t as bitcoindRpc } from "./bitcoindProvider-DGw3xBSa.js";
5
- import { t as nwc } from "./nwcProvider-D482SQ1a.js";
6
- 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-B5L8wuOm.js";
7
- import { a as sealSeed, i as newDeviceKey, o as unsealSeed, r as intentRepository } from "./networkReason-vUHo6A_C.js";
8
- import { $ as setSingleWriterGate, A as LightningCards, B as breezDatabaseInUse, C as FileAppender, D as streamStored, E as storedSize, F as ProviderSources, G as ModeChanged, H as dropBreezDatabase, I as sourceKey, J as networkLabel, K as ModeGate, L as CASHU_MINT_SOURCE, M as isRecoveryPhrase, N as normalizePhrase, P as lightningPaying, Q as setAwayWallets, R as cashuMint, S as WALLET_TYPES, T as removeStored, U as iceServerProblem, V as breezDatabasesFor, W as iceServerSetting, X as refuseAway, Y as awayFrom, Z as requireTurn, _ as crossNetwork, a as storedGroupFiles, b as walletInstances, c as receivedPairedReply, d as paymentAlias, f as paymentWireId, g as createTiming, h as createFailure, i as groupOfFile, j as cardsKey, k as CASHU_CARD, l as replyRef, m as assertConfirmedReal, n as groupFileId, o as groupReply, p as WALLET_NAMES, q as WrongNetworkError, r as groupFileText, s as pairedWireReply, t as GroupFileDesk, u as replyTo, v as paymentNetwork, w as readStored$1, y as paymentNetworksOf, z as breezDatabase } from "./groupFiles-uxa0U9_n.js";
9
- import { i as fedimintDatabase, n as createIrohWebEndpoint, o as loadFedimintSdk, r as irohRelayProblem, t as DEFAULT_IROH_RELAYS } from "./irohWeb-z5QDDWa-.js";
5
+ import { t as nwc } from "./nwcProvider-BKNFdH9v.js";
6
+ import { a as clearProfileStores, c as errorLine, d as openDb, f as profileOpenFailure, h as wrap, i as STORES$1, l as fileStore, m as transact, n as APP_STORAGE_SCOPE_INDEX, o as databaseName, p as store, r as CARD_INDEX, s as deleteDatabase, t as APP_STORAGE_CHAT_INDEX, u as newestWalletTx } from "./idb-CMVgtn2k.js";
7
+ import { a as sealSeed, i as newDeviceKey, o as unsealSeed, r as intentRepository } from "./networkReason-B91upMCb.js";
8
+ import { $ as setSingleWriterGate, A as LightningCards, B as breezDatabaseInUse, C as FileAppender, D as streamStored, E as storedSize, F as ProviderSources, G as ModeChanged, H as dropBreezDatabase, I as sourceKey, J as networkLabel, K as ModeGate, L as CASHU_MINT_SOURCE, M as isRecoveryPhrase, N as normalizePhrase, P as lightningPaying, Q as setAwayWallets, R as cashuMint, S as WALLET_TYPES, T as removeStored, U as iceServerProblem, V as breezDatabasesFor, W as iceServerSetting, X as refuseAway, Y as awayFrom, Z as requireTurn, _ as crossNetwork, a as storedGroupFiles, b as walletInstances, c as receivedPairedReply, d as paymentAlias, f as paymentWireId, g as createTiming, h as createFailure, i as groupOfFile, j as cardsKey, k as CASHU_CARD, l as replyRef, m as assertConfirmedReal, n as groupFileId, o as groupReply, p as WALLET_NAMES, q as WrongNetworkError, r as groupFileText, s as pairedWireReply, t as GroupFileDesk, u as replyTo, v as paymentNetwork, w as readStored$1, y as paymentNetworksOf, z as breezDatabase } from "./groupFiles-sIdGq4AV.js";
9
+ import { i as fedimintDatabase, n as createIrohWebEndpoint, o as loadFedimintSdk, r as irohRelayProblem, t as DEFAULT_IROH_RELAYS } from "./irohWeb-DHCxSvHF.js";
10
10
  import { c as isTestMint, d as paysItsOwnInvoices, l as isWorthlessMint, n as DEFAULT_MINTS, o as fakesLightning, r as TEST_MINT, t as BITCOIN_INVOICE_ON_TESTNET, u as mintNetwork } from "./mints-BrlY773j.js";
11
- import { _ as reserveAdapterIds, b as testBuild, f as setOwnDidSource, g as registeredOnchainProviders, h as registeredLightningProviders, l as chosenResolver, n as resolveAtprotoDid, p as onAdaptersChanged, t as assertPublicServer, v as boundedIdentityFetch, y as verifyIdentity } from "./resolve-CPTAnu-9.js";
12
- import { c as fileSpace, f as FILE_BYTES_STEP, o as fileBytes, r as blobDigest, s as fileBytesOf, u as removeFileBytes } from "./fileBytes-Di7VZtmU.js";
13
- import { n as MAX_BACKUP_BYTES, t as BACKUP_MEDIA_TYPE } from "./envelope-DW8TWKPD.js";
11
+ import { _ as reserveAdapterIds, b as testBuild, f as setOwnDidSource, g as registeredOnchainProviders, h as registeredLightningProviders, l as chosenResolver, n as resolveAtprotoDid, p as onAdaptersChanged, t as assertPublicServer, v as boundedIdentityFetch, y as verifyIdentity } from "./resolve-B63_uSu7.js";
12
+ import { c as fileSpace, f as FILE_BYTES_STEP, o as fileBytes, r as blobDigest, s as fileBytesOf, u as removeFileBytes } from "./fileBytes-B-fqGdEd.js";
13
+ import { n as MAX_BACKUP_BYTES, t as BACKUP_MEDIA_TYPE } from "./envelope-C7vnmsJN.js";
14
14
  import { t as hyperdhtRelayProblem } from "./hyperdhtRelay-BT_wRxab.js";
15
15
  import { sha256 } from "@noble/hashes/sha2.js";
16
16
  import { hkdf } from "@noble/hashes/hkdf.js";
17
17
  import { base64, base64url, base64urlnopad, bech32, hex } from "@scure/base";
18
- import { bytesToHex, concatBytes, hexToBytes, utf8ToBytes } from "@noble/hashes/utils.js";
19
18
  import { chacha20poly1305 } from "@noble/ciphers/chacha.js";
19
+ import { bytesToHex, concatBytes, hexToBytes, utf8ToBytes } from "@noble/hashes/utils.js";
20
20
  import { secp256k1 } from "@noble/curves/secp256k1.js";
21
21
  import { verifyEvent } from "nostr-tools/pure";
22
22
  import { generateMnemonic, mnemonicToSeedSync, validateMnemonic } from "@scure/bip39";
23
23
  import { wordlist } from "@scure/bip39/wordlists/english.js";
24
- import { Amount, MeltChangeError, OutputData, Wallet, decodePaymentRequest, deserializeSwapPreview, getEncodedToken, getTokenMetadata, isMintOperationError, serializeSwapPreview } from "@cashu/cashu-ts";
25
24
  import { decode, noteEncode, npubEncode } from "nostr-tools/nip19";
26
25
  /** Evidence older than this says nothing about the clock now. */
27
26
  var CLOCK_EVIDENCE_MS = 18e5;
@@ -199,7 +198,7 @@ function viewNews(before, after) {
199
198
  //#endregion
200
199
  //#region ../browser/src/engine/paymentAdapters/usdtWallet.ts
201
200
  /** The Ethereum libraries, a large part of the app: loaded once a USDT wallet is made, opened or restored, not with the engine. */
202
- var loadUsdt = () => import("./usdt-BY5OIIvG.js");
201
+ var loadUsdt = () => import("./usdt-B7JWDhQV.js");
203
202
  /** Every new profile starts with this wallet: an address to receive on, nothing to set up. */
204
203
  var DEFAULT_USDT = {
205
204
  network: "ethereum",
@@ -648,7 +647,7 @@ async function restoreArkDatabase(walletId, snapshot) {
648
647
  //#endregion
649
648
  //#region ../browser/src/engine/paymentAdapters/arkWallet.ts
650
649
  /** The Ark SDK, a large part of the app: loaded once an Ark wallet is made, opened or restored, not with the engine. */
651
- var loadArkade = () => import("./arkade-Cyf5MGxG.js");
650
+ var loadArkade = () => import("./arkade-CP4HvKaa.js");
652
651
  /** How long a Testnet wallet about to make way for another waits for its server to say what it holds. */
653
652
  var RETIRE_READ_MS = 1e4;
654
653
  /** Every new profile starts with this wallet: nothing to set up before receiving. */
@@ -3556,7 +3555,7 @@ var CashuAdapter = class {
3556
3555
  token = await this.wallet.executeReviewedCashu(review, prepared);
3557
3556
  } catch (error) {
3558
3557
  prepared.attemptEndedAt = Date.now();
3559
- if (persist && isMintOperationError(error) && await this.wallet.reviewedCashuSpentElsewhere(prepared).catch(() => false)) return this.spentElsewhere(prepared, persist);
3558
+ if (persist && (await import("@cashu/cashu-ts")).isMintOperationError(error) && await this.wallet.reviewedCashuSpentElsewhere(prepared).catch(() => false)) return this.spentElsewhere(prepared, persist);
3560
3559
  throw error;
3561
3560
  }
3562
3561
  await persist?.();
@@ -6703,7 +6702,7 @@ var registry;
6703
6702
  var loading$1;
6704
6703
  var listeners = /* @__PURE__ */ new Set();
6705
6704
  function loadIdentityProviders() {
6706
- return loading$1 ??= import("./registry-CxsDR3c6.js").then((m) => {
6705
+ return loading$1 ??= import("./registry-CHPd_hf7.js").then((m) => {
6707
6706
  registry = m;
6708
6707
  for (const listener of listeners) listener();
6709
6708
  return m;
@@ -7132,16 +7131,176 @@ var IdentityProofs = class {
7132
7131
  }
7133
7132
  };
7134
7133
  //#endregion
7134
+ //#region ../browser/src/engine/x401.ts
7135
+ var X401_KEY = "x401Credentials";
7136
+ var MAX_CREDENTIALS = 32;
7137
+ /** Keys waiting for an issuer: the oldest goes when one more is made. */
7138
+ var MAX_WAITING_KEYS = 8;
7139
+ var ASK_FOR_A_NEW_KEY = "ask the issuer to bind it to a key from x401 key new";
7140
+ var LABEL_CHARS = 80;
7141
+ var newId$1 = () => toBase64Url(crypto.getRandomValues(/* @__PURE__ */ new Uint8Array(12)));
7142
+ /** The paths of an object's members, nested objects' own members included. */
7143
+ function paths(value, at = []) {
7144
+ return Object.entries(value).flatMap(([key, item]) => {
7145
+ const path = [...at, key];
7146
+ return typeof item === "object" && item !== null && !Array.isArray(item) ? [path, ...paths(item, path)] : [path];
7147
+ });
7148
+ }
7149
+ /** The record's retired keys with `more` after them: the latest `MAX_RETIRED_KEYS`. */
7150
+ var retire = (stored, more) => [...stored.retired ?? [], ...more].slice(-32);
7151
+ /** Why a credential's key is not one waiting for it: what became of it, when this profile made it. */
7152
+ function notWaiting(stored, holder) {
7153
+ const held = stored.credentials.find((c) => sameX401HolderJwk(c.holderKey.jwk, holder));
7154
+ if (held) return `This credential's key already holds credential ${held.id} (${held.label}): a key takes one credential`;
7155
+ const retired = [...stored.retired ?? []].reverse().find((r) => sameX401HolderJwk(r.jwk, holder));
7156
+ if (retired?.credential) return `This credential's key went with credential ${retired.credential} when it was removed`;
7157
+ if (retired) return `This credential's key was dropped: a profile keeps the ${MAX_WAITING_KEYS} newest keys waiting for a credential`;
7158
+ return "This credential is bound to a key this profile did not make";
7159
+ }
7160
+ /**
7161
+ * x401 credentials (WISP 3xx), engine side: the profile's holder keys and the credentials issuers bound to them, in
7162
+ * the settings store beside the identity proofs. Read from the store on each call: nothing of it stays in memory.
7163
+ * Nothing here is shared with a contact, and no presentation is made here yet.
7164
+ */
7165
+ var X401Credentials = class {
7166
+ /** Changes one after another: each reads the record and writes it back, and two at once would lose one. */
7167
+ queue = Promise.resolve();
7168
+ serial(change) {
7169
+ const next = this.queue.then(change, change);
7170
+ this.queue = next.catch(() => {});
7171
+ return next;
7172
+ }
7173
+ async load() {
7174
+ return await wrap((await store(STORES$1.settings, "readonly")).get("x401Credentials")) ?? {
7175
+ keys: [],
7176
+ credentials: []
7177
+ };
7178
+ }
7179
+ async save(value) {
7180
+ await transact([STORES$1.settings], (stores) => {
7181
+ stores[STORES$1.settings].put(value, X401_KEY);
7182
+ });
7183
+ }
7184
+ /**
7185
+ * A fresh P-256 holder key, kept until a credential bound to it is added. Its public JWK goes to the issuer. A profile
7186
+ * keeps the 8 newest keys waiting: past them, the oldest is dropped, and `dropped` names it.
7187
+ */
7188
+ async newKey() {
7189
+ const { secret, jwk } = newX401HolderKey();
7190
+ const deviceKey = newDeviceKey();
7191
+ const key = {
7192
+ id: newId$1(),
7193
+ jwk,
7194
+ secret: {
7195
+ sealed: await sealSeed(toBase64Url(secret), deviceKey),
7196
+ deviceKey
7197
+ },
7198
+ createdAt: Date.now()
7199
+ };
7200
+ secret.fill(0);
7201
+ return this.serial(async () => {
7202
+ const stored = await this.load();
7203
+ const keys = [...stored.keys, key];
7204
+ const dropped = keys.slice(0, -8);
7205
+ await this.save({
7206
+ ...stored,
7207
+ keys: keys.slice(-8),
7208
+ retired: retire(stored, dropped.map((k) => ({ jwk: k.jwk })))
7209
+ });
7210
+ return {
7211
+ id: key.id,
7212
+ jwk,
7213
+ ...dropped.length ? { dropped: dropped[0].id } : {}
7214
+ };
7215
+ });
7216
+ }
7217
+ /**
7218
+ * Adds an issued credential bound to one of the keys `newKey` made. Refused: a credential that does not read, one
7219
+ * that has expired, one bound to another key, one already kept, a label past `LABEL_CHARS` (it was cut without a
7220
+ * word). A label is one line: its line breaks and tabs read as one space, as a group's name.
7221
+ */
7222
+ add({ credential, label: given }) {
7223
+ const label = given?.replace(/\s+/g, " ").trim();
7224
+ if (label && ([...label].length > LABEL_CHARS || /\p{Cc}/u.test(label))) return Promise.reject(/* @__PURE__ */ new Error(`A credential's label is up to ${LABEL_CHARS} characters on one line`));
7225
+ const read = readX401SdJwtVc(credential);
7226
+ if (!read.ok) return Promise.reject(/* @__PURE__ */ new Error(`Not an SD-JWT VC Ghostly can keep: ${read.reason}`));
7227
+ const vc = read.value;
7228
+ if (vc.expiresAt !== void 0 && vc.expiresAt * 1e3 <= Date.now()) return Promise.reject(/* @__PURE__ */ new Error(`This credential expired on ${(/* @__PURE__ */ new Date(vc.expiresAt * 1e3)).toISOString()}`));
7229
+ return this.serial(() => this.keep(credential.trim(), vc, label));
7230
+ }
7231
+ async keep(credential, vc, label) {
7232
+ const stored = await this.load();
7233
+ if (stored.credentials.some((c) => c.credential === credential)) throw new Error("This credential is kept already");
7234
+ const holderKey = stored.keys.find((k) => sameX401HolderJwk(k.jwk, vc.holder));
7235
+ if (!holderKey) throw new Error(`${notWaiting(stored, vc.holder)}: ${ASK_FOR_A_NEW_KEY}`);
7236
+ if (stored.credentials.length >= MAX_CREDENTIALS) throw new Error(`A profile keeps at most ${MAX_CREDENTIALS} credentials: remove one first`);
7237
+ const kept = {
7238
+ id: newId$1(),
7239
+ label: label || vc.type.slice(0, LABEL_CHARS),
7240
+ format: vc.format,
7241
+ credential,
7242
+ type: vc.type,
7243
+ issuer: vc.issuer,
7244
+ holderKey,
7245
+ addedAt: Date.now(),
7246
+ ...vc.expiresAt !== void 0 ? { expiresAt: vc.expiresAt * 1e3 } : {}
7247
+ };
7248
+ await this.save({
7249
+ ...stored,
7250
+ keys: stored.keys.filter((k) => k !== holderKey),
7251
+ credentials: [...stored.credentials, kept]
7252
+ });
7253
+ return this.viewOf(kept, false);
7254
+ }
7255
+ async list({ values = false } = {}) {
7256
+ return (await this.load()).credentials.map((c) => this.viewOf(c, values));
7257
+ }
7258
+ /** Removes a credential and its holder key. False when the profile has no credential with that id. */
7259
+ remove({ id }) {
7260
+ return this.serial(async () => {
7261
+ const stored = await this.load();
7262
+ const gone = stored.credentials.find((c) => c.id === id);
7263
+ if (!gone) return false;
7264
+ await this.save({
7265
+ ...stored,
7266
+ credentials: stored.credentials.filter((c) => c !== gone),
7267
+ retired: retire(stored, [{
7268
+ jwk: gone.holderKey.jwk,
7269
+ credential: id
7270
+ }])
7271
+ });
7272
+ return true;
7273
+ });
7274
+ }
7275
+ viewOf(c, values) {
7276
+ const read = readX401SdJwtVc(c.credential);
7277
+ const claims = read.ok ? read.value.claims : {};
7278
+ return {
7279
+ id: c.id,
7280
+ label: c.label,
7281
+ format: c.format,
7282
+ type: c.type,
7283
+ issuer: c.issuer,
7284
+ addedAt: c.addedAt,
7285
+ expiresAt: c.expiresAt ?? null,
7286
+ notBefore: read.ok && read.value.notBefore !== void 0 ? read.value.notBefore * 1e3 : null,
7287
+ claims: paths(claims),
7288
+ disclosable: read.ok ? read.value.disclosable : [],
7289
+ ...values ? { values: claims } : {}
7290
+ };
7291
+ }
7292
+ };
7293
+ //#endregion
7135
7294
  //#region ../browser/src/engine/did.ts
7136
7295
  var KEY$1 = "profileDid";
7137
7296
  /** Mainline keeps a mutable item about two hours: the same packet goes out again every hour. */
7138
7297
  var DID_REPUBLISH_MS = 36e5;
7139
7298
  /** After start, once the chats had their first go at the relays. */
7140
- var FIRST_PUBLISH_MS = 15e3;
7299
+ var FIRST_PUBLISH_MS$1 = 15e3;
7141
7300
  /** A few switches flipped in a row make one publish. */
7142
7301
  var CHANGE_DELAY_MS = 2e3;
7143
7302
  /** A failed publish is tried again this much later (not before the hourly one otherwise). */
7144
- var RETRY_MS$1 = 3e5;
7303
+ var RETRY_MS$2 = 3e5;
7145
7304
  /** Relay payload header: signature and sequence number, before the DNS packet. */
7146
7305
  var HEADER = 72;
7147
7306
  var ProfileDid = class {
@@ -7179,7 +7338,7 @@ var ProfileDid = class {
7179
7338
  }
7180
7339
  start() {
7181
7340
  this.running = true;
7182
- this.schedule(FIRST_PUBLISH_MS);
7341
+ this.schedule(FIRST_PUBLISH_MS$1);
7183
7342
  }
7184
7343
  stop() {
7185
7344
  this.running = false;
@@ -7265,7 +7424,7 @@ var ProfileDid = class {
7265
7424
  this.timer = setTimeout(() => {
7266
7425
  this.timer = null;
7267
7426
  this.publishNow().finally(() => {
7268
- if (this.running && !this.timer) this.schedule(this.error ? RETRY_MS$1 : DID_REPUBLISH_MS);
7427
+ if (this.running && !this.timer) this.schedule(this.error ? RETRY_MS$2 : DID_REPUBLISH_MS);
7269
7428
  });
7270
7429
  }, ms);
7271
7430
  }
@@ -7346,6 +7505,558 @@ var ProfileDid = class {
7346
7505
  this.stored = next;
7347
7506
  }
7348
7507
  };
7508
+ //#endregion
7509
+ //#region ../browser/src/engine/contactLinks.ts
7510
+ var CONTACT_LINKS_KEY = "contactLinks";
7511
+ /** Mainline keeps a mutable item about two hours: the link record goes out again every hour. */
7512
+ var LINK_REPUBLISH_MS = 36e5;
7513
+ var OFF_FOR_MS = 2592e6;
7514
+ var FIRST_PUBLISH_MS = 15e3;
7515
+ var RETRY_MS$1 = 3e5;
7516
+ /** How often the knock slots are read: every five minutes, and every 15 s for ten minutes after the link or Requests was shown. */
7517
+ var POLL_MS = 3e5;
7518
+ var POLL_SHOWN_MS = 15e3;
7519
+ var SHOWN_FOR_MS = 6e5;
7520
+ var ContactLinks = class {
7521
+ host;
7522
+ stored = { retired: [] };
7523
+ error;
7524
+ timer = null;
7525
+ running = false;
7526
+ inFlight = null;
7527
+ again = false;
7528
+ writes = Promise.resolve();
7529
+ pollTimer = null;
7530
+ shownAt = -Infinity;
7531
+ /** Making, turning off, taking and settling requests run one at a time: each reads and writes the whole record. */
7532
+ queue = Promise.resolve();
7533
+ constructor(host) {
7534
+ this.host = host;
7535
+ }
7536
+ async load() {
7537
+ const found = await wrap((await store(STORES$1.settings, "readonly")).get(CONTACT_LINKS_KEY));
7538
+ this.stored = found ?? { retired: [] };
7539
+ }
7540
+ start() {
7541
+ this.running = true;
7542
+ this.schedule(FIRST_PUBLISH_MS);
7543
+ this.schedulePoll(FIRST_PUBLISH_MS);
7544
+ }
7545
+ stop() {
7546
+ this.running = false;
7547
+ if (this.timer) clearTimeout(this.timer);
7548
+ this.timer = null;
7549
+ if (this.pollTimer) clearTimeout(this.pollTimer);
7550
+ this.pollTimer = null;
7551
+ }
7552
+ /**
7553
+ * The link was shown or copied, or Requests opened: people open a link in the minutes after it is shared, and the
7554
+ * owner looking at Requests wants what came. Look now, and more often for ten minutes.
7555
+ */
7556
+ shown() {
7557
+ this.shownAt = Date.now();
7558
+ this.schedulePoll(0);
7559
+ }
7560
+ /** Whether a link key is one of this profile's, current or turned off. */
7561
+ owns(linkKeyZ32) {
7562
+ return this.stored.current?.linkKeyZ32 === linkKeyZ32 || this.stored.retired.some((link) => link.linkKeyZ32 === linkKeyZ32);
7563
+ }
7564
+ /** The requests waiting, newest first. */
7565
+ requests() {
7566
+ return [...this.stored.current?.requests ?? []].reverse().map((r) => ({
7567
+ id: r.key,
7568
+ name: r.name,
7569
+ text: r.text,
7570
+ receivedAt: r.receivedAt,
7571
+ expires: r.expires
7572
+ }));
7573
+ }
7574
+ /**
7575
+ * Accept: the owner joins the invite the request carries, and the chat starts as any chat does. The request leaves
7576
+ * Requests and its asker key is never read again on this link.
7577
+ */
7578
+ accept(id) {
7579
+ return this.serial(async () => {
7580
+ const request = this.stored.current?.requests?.find((r) => r.key === id);
7581
+ if (!request) throw new Error("This request is no longer waiting");
7582
+ if (request.expires <= Date.now()) throw new Error("This request expired");
7583
+ const linkId = await this.host.accept(request);
7584
+ await this.settle(id);
7585
+ return { linkId };
7586
+ });
7587
+ }
7588
+ /** Decline: nothing is said back. The request's name and text are deleted, and its asker key is never read again. */
7589
+ decline(id) {
7590
+ return this.serial(() => this.settle(id));
7591
+ }
7592
+ /** Reads the knock slots once and takes what is new into Requests. */
7593
+ poll() {
7594
+ return this.serial(() => this.take());
7595
+ }
7596
+ /** Undefined while the profile has no link. */
7597
+ view() {
7598
+ const current = this.stored.current;
7599
+ if (!current) return void 0;
7600
+ const code = encodeContactLink(current.linkKeyZ32);
7601
+ return {
7602
+ code,
7603
+ link: inviteLink(code),
7604
+ createdAt: current.createdAt,
7605
+ state: current.inbox.state === "busy" ? "busy" : "open",
7606
+ waiting: current.inbox.waiting.length,
7607
+ missed: current.inbox.missed.length,
7608
+ ...current.published ? { publishedAt: current.published } : {},
7609
+ ...this.error ? { error: this.error } : {}
7610
+ };
7611
+ }
7612
+ /** Makes the profile's link, turning the one before off: one link per profile at a time. Requests keeps what waits. */
7613
+ create() {
7614
+ return this.serial(() => this.make());
7615
+ }
7616
+ async make() {
7617
+ const made = createContactLink();
7618
+ const deviceKey = newDeviceKey();
7619
+ const now = Date.now();
7620
+ const retired = this.retire(now);
7621
+ const waiting = this.stored.current?.inbox.waiting.filter((w) => w.expires > now) ?? [];
7622
+ const requests = this.stored.current?.requests?.filter((r) => waiting.some((w) => w.key === r.key)) ?? [];
7623
+ const inbox = {
7624
+ ...newInbox(now),
7625
+ waiting,
7626
+ state: waiting.length >= 32 ? "busy" : "open"
7627
+ };
7628
+ await this.save({
7629
+ current: {
7630
+ seed: {
7631
+ sealed: await sealSeed(made.seedB64, deviceKey),
7632
+ deviceKey
7633
+ },
7634
+ linkKeyZ32: made.linkKeyZ32,
7635
+ createdAt: now,
7636
+ inbox,
7637
+ ...requests.length ? { requests } : {}
7638
+ },
7639
+ retired
7640
+ });
7641
+ this.host.emit();
7642
+ await this.publishNow();
7643
+ return this.view();
7644
+ }
7645
+ /** Turns the link off, for good: requesters who open it are told so. */
7646
+ turnOff() {
7647
+ return this.serial(() => this.off());
7648
+ }
7649
+ async off() {
7650
+ if (!this.stored.current) return;
7651
+ await this.save({ retired: this.retire(Date.now()) });
7652
+ this.host.emit();
7653
+ await this.publishNow();
7654
+ }
7655
+ /** "Clear all data": the link says `off` once, now, and nothing is kept. */
7656
+ async withdraw() {
7657
+ this.stop();
7658
+ const links = [...this.stored.current ? [this.stored.current] : [], ...this.stored.retired];
7659
+ await Promise.allSettled(links.map(async (link) => this.host.publish(await this.identity(link), linkRecords(inboxRecord(turnOff(newInbox(Date.now())), Date.now())))));
7660
+ }
7661
+ /** Publishes what is due now: the current link's record, and `off` for each retired link not said today. */
7662
+ async publishNow() {
7663
+ if (this.inFlight) {
7664
+ this.again = true;
7665
+ return this.inFlight;
7666
+ }
7667
+ this.inFlight = this.publish().finally(() => {
7668
+ this.inFlight = null;
7669
+ });
7670
+ await this.inFlight;
7671
+ if (this.again) {
7672
+ this.again = false;
7673
+ await this.publishNow();
7674
+ }
7675
+ }
7676
+ serial(work) {
7677
+ const run = this.queue.then(work, work);
7678
+ this.queue = run.catch(() => {});
7679
+ return run;
7680
+ }
7681
+ schedulePoll(ms) {
7682
+ if (!this.running) return;
7683
+ if (this.pollTimer) clearTimeout(this.pollTimer);
7684
+ this.pollTimer = setTimeout(() => {
7685
+ this.pollTimer = null;
7686
+ this.poll().catch(() => {}).finally(() => {
7687
+ if (!this.running || this.pollTimer) return;
7688
+ const shown = Date.now() - this.shownAt < SHOWN_FOR_MS;
7689
+ this.schedulePoll(shown ? POLL_SHOWN_MS : POLL_MS);
7690
+ });
7691
+ }, ms);
7692
+ }
7693
+ async take() {
7694
+ const current = this.stored.current;
7695
+ if (!current || !this.host.online()) return;
7696
+ const now = Date.now();
7697
+ const linkKey = current.linkKeyZ32;
7698
+ const slots = await Promise.all(Array.from({ length: 4 }, (_, i) => this.host.resolve(knockSlotIdentity(linkKey, i).pubKeyZ32).catch(() => null)));
7699
+ let inbox = current.inbox;
7700
+ for (const key of endedRequests(inbox, now)) inbox = settleRequest(inbox, key, "ended");
7701
+ const plan = planKnocks(linkKey, inbox, slots.flatMap((records) => records ? readKnockSlot(linkKey, records) : []), now);
7702
+ inbox = plan.inbox;
7703
+ const taken = [];
7704
+ if (plan.read.length) {
7705
+ const { seedB64 } = await this.identity(current);
7706
+ for (const key of plan.read) {
7707
+ const records = await this.host.resolve(key).catch(() => null);
7708
+ const request = records ? openRequest(seedB64, key, records, now) : null;
7709
+ if (!request || this.host.ownInvite(request.invite)) continue;
7710
+ inbox = takeRequest(linkKey, inbox, key, request.expires, now);
7711
+ taken.push({
7712
+ ...request,
7713
+ key,
7714
+ receivedAt: now
7715
+ });
7716
+ }
7717
+ }
7718
+ const latest = this.stored.current;
7719
+ if (!latest || latest.linkKeyZ32 !== linkKey) return;
7720
+ const requests = [...current.requests ?? [], ...taken].filter((r) => inbox.waiting.some((w) => w.key === r.key));
7721
+ const changed = plan.publish || taken.length > 0 || inbox.state !== current.inbox.state || inbox.bits !== current.inbox.bits;
7722
+ await this.save({
7723
+ ...this.stored,
7724
+ current: {
7725
+ ...latest,
7726
+ inbox,
7727
+ requests
7728
+ }
7729
+ });
7730
+ this.host.emit();
7731
+ if (changed) await this.publishNow();
7732
+ }
7733
+ async settle(key) {
7734
+ const current = this.stored.current;
7735
+ if (!current?.requests?.some((r) => r.key === key)) return;
7736
+ const inbox = clearMissed(settleRequest(current.inbox, key));
7737
+ await this.save({
7738
+ ...this.stored,
7739
+ current: {
7740
+ ...current,
7741
+ inbox,
7742
+ requests: current.requests.filter((r) => r.key !== key)
7743
+ }
7744
+ });
7745
+ this.host.emit();
7746
+ if (inbox.state !== current.inbox.state) await this.publishNow();
7747
+ }
7748
+ retire(now) {
7749
+ const kept = this.stored.retired.filter((link) => now - link.offAt < OFF_FOR_MS);
7750
+ const current = this.stored.current;
7751
+ return current ? [...kept, {
7752
+ seed: current.seed,
7753
+ linkKeyZ32: current.linkKeyZ32,
7754
+ createdAt: current.createdAt,
7755
+ offAt: now
7756
+ }] : kept;
7757
+ }
7758
+ schedule(ms) {
7759
+ if (!this.running) return;
7760
+ if (this.timer) clearTimeout(this.timer);
7761
+ this.timer = setTimeout(() => {
7762
+ this.timer = null;
7763
+ this.publishNow().finally(() => {
7764
+ if (this.running && !this.timer) this.schedule(this.error ? RETRY_MS$1 : LINK_REPUBLISH_MS);
7765
+ });
7766
+ }, ms);
7767
+ }
7768
+ async publish() {
7769
+ if (!this.host.online()) return;
7770
+ const now = Date.now();
7771
+ const { current, retired } = this.stored;
7772
+ let published = false;
7773
+ const said = /* @__PURE__ */ new Set();
7774
+ this.error = void 0;
7775
+ if (current) try {
7776
+ await this.host.publish(await this.identity(current), linkRecords(inboxRecord(current.inbox, now)));
7777
+ published = true;
7778
+ } catch (error) {
7779
+ this.error = error instanceof Error ? error.message : String(error);
7780
+ }
7781
+ for (const link of retired) {
7782
+ if (now - link.offAt >= 2592e6 || link.said !== void 0 && now - link.said < 864e5) continue;
7783
+ try {
7784
+ await this.host.publish(await this.identity(link), linkRecords(inboxRecord(turnOff(newInbox(now)), now)));
7785
+ said.add(link.linkKeyZ32);
7786
+ } catch {}
7787
+ }
7788
+ const latest = this.stored;
7789
+ await this.save({
7790
+ ...latest.current ? { current: published && latest.current.linkKeyZ32 === current?.linkKeyZ32 ? {
7791
+ ...latest.current,
7792
+ published: now
7793
+ } : latest.current } : {},
7794
+ retired: latest.retired.filter((link) => now - link.offAt < OFF_FOR_MS).map((link) => said.has(link.linkKeyZ32) ? {
7795
+ ...link,
7796
+ said: now
7797
+ } : link)
7798
+ });
7799
+ this.host.emit();
7800
+ }
7801
+ async identity(link) {
7802
+ return identityFromSeedB64(await unsealSeed(link.seed.sealed, link.seed.deviceKey));
7803
+ }
7804
+ /**
7805
+ * The record is the one in memory from now on, and the writes go to the database in the order they were made: a
7806
+ * publish that ends while a request is being taken can never put back the record from before it.
7807
+ */
7808
+ save(next) {
7809
+ this.stored = next;
7810
+ const write = this.writes.then(async () => {
7811
+ await wrap((await store(STORES$1.settings, "readwrite")).put(next, CONTACT_LINKS_KEY));
7812
+ });
7813
+ this.writes = write.catch(() => {});
7814
+ return write;
7815
+ }
7816
+ };
7817
+ //#endregion
7818
+ //#region ../browser/src/engine/contactAsks.ts
7819
+ var CONTACT_ASKS_KEY = "contactAsks";
7820
+ /** How often a request not yet seen is looked after: every 30 s for ten minutes, then every ten minutes. */
7821
+ var ASK_FAST_MS = 3e4;
7822
+ var ASK_SLOW_MS = 6e5;
7823
+ /** After a knock, its slot is read again this much later, twice at most: another requester may have written at once. */
7824
+ var KNOCK_CHECK_MS = 1e3;
7825
+ var SLACK_MS = 5e3;
7826
+ var sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
7827
+ var ContactAsks = class {
7828
+ host;
7829
+ asks = [];
7830
+ timer = null;
7831
+ running = false;
7832
+ queue = Promise.resolve();
7833
+ constructor(host) {
7834
+ this.host = host;
7835
+ }
7836
+ async load() {
7837
+ this.asks = await wrap((await store(STORES$1.settings, "readonly")).get("contactAsks")) ?? [];
7838
+ }
7839
+ start() {
7840
+ this.running = true;
7841
+ this.schedule(ASK_FAST_MS);
7842
+ }
7843
+ stop() {
7844
+ this.running = false;
7845
+ if (this.timer) clearTimeout(this.timer);
7846
+ this.timer = null;
7847
+ }
7848
+ views() {
7849
+ return this.asks.filter((a) => a.chatId && a.state).map((a) => ({
7850
+ chatId: a.chatId,
7851
+ state: a.state,
7852
+ expires: a.expires
7853
+ }));
7854
+ }
7855
+ /**
7856
+ * Sends a request through a contact link. Refused, with the words the person reads, for a code that is not a contact
7857
+ * link, the profile's own link, a link turned off or busy, a request already waiting there, or a name or text that
7858
+ * breaks a bound. Nothing is made before every check passed.
7859
+ */
7860
+ ask(params) {
7861
+ return this.serial(() => this.send(params));
7862
+ }
7863
+ /**
7864
+ * The chat a request made was deleted: the request is taken back. Nothing more goes out for it, its record under the
7865
+ * asker key is replaced by an empty one so an owner who has not read it yet finds nothing, and asking again through
7866
+ * the link is open. The asker key stays, one per profile and link. An owner who already took it keeps it until it ends.
7867
+ */
7868
+ forgetChat(chatId) {
7869
+ return this.serial(async () => {
7870
+ const ask = this.asks.find((a) => a.chatId === chatId);
7871
+ if (!ask) return;
7872
+ const out = ask.state === "sending" || ask.state === "knocking" || ask.state === "seen";
7873
+ await this.put({
7874
+ link: ask.link,
7875
+ seed: ask.seed,
7876
+ key: ask.key
7877
+ });
7878
+ this.host.emit();
7879
+ if (!out || !this.host.online()) return;
7880
+ try {
7881
+ await this.host.publish(await this.identity(ask), []);
7882
+ } catch {}
7883
+ });
7884
+ }
7885
+ /** Looks after every request not yet seen once (the timer does this at its pace). */
7886
+ tick() {
7887
+ return this.serial(() => this.look());
7888
+ }
7889
+ async send({ code, name, text, label }) {
7890
+ const reading = readContactLink(code);
7891
+ if (!reading.ok) throw new Error(reading.reason === "chat" ? "This is a chat invite: open it in Join." : "This is not a Ghostly contact link.");
7892
+ const link = reading.linkKeyZ32;
7893
+ if (this.host.ownLink(link)) throw new Error("This is your contact link.");
7894
+ const now = Date.now();
7895
+ const known = this.asks.find((a) => a.link === link);
7896
+ if (known?.state && known.state !== "ended" && known.state !== "off" && (known.expires ?? 0) > now) throw new Error("You already sent a request through this link.");
7897
+ const record = readLinkRecord(await this.host.resolve(link) ?? []);
7898
+ if (record?.state === "off") throw new Error("This contact link was turned off.");
7899
+ if (record?.state === "busy") throw new Error("They are not taking requests right now. Try again later.");
7900
+ const asker = known ? await this.identity(known) : createIdentity();
7901
+ const request = (invite) => ({
7902
+ invite,
7903
+ name: name.trim(),
7904
+ text: text.trim(),
7905
+ issued: now,
7906
+ expires: now + REQUEST_LIFETIME_MS
7907
+ });
7908
+ try {
7909
+ requestRecords(asker.seedB64, link, request(createChatInvite().inviteCode));
7910
+ } catch (error) {
7911
+ if (!(error instanceof ContactRequestError)) throw error;
7912
+ throw new Error(error.problem === "name" ? "Write a name of up to 48 characters." : error.problem === "too-long" ? "Shorten the message." : "Write a message of up to 200 bytes.");
7913
+ }
7914
+ const chat = await this.host.createChat({
7915
+ ...label?.trim() ? { label: label.trim() } : {},
7916
+ text: text.trim()
7917
+ });
7918
+ const sent = request(chat.inviteCode);
7919
+ const ask = {
7920
+ link,
7921
+ key: asker.pubKeyZ32,
7922
+ seed: known?.seed ?? await this.seal(asker),
7923
+ chatId: chat.linkId,
7924
+ invite: chat.inviteCode,
7925
+ name: sent.name,
7926
+ text: sent.text,
7927
+ issued: now,
7928
+ expires: sent.expires,
7929
+ state: "sending"
7930
+ };
7931
+ await this.put(ask);
7932
+ this.tick().catch(() => {});
7933
+ this.schedule(ASK_FAST_MS);
7934
+ return { chatId: chat.linkId };
7935
+ }
7936
+ /** Publishes the request when due and knocks when its knock is gone. Never throws: the next look tries again. */
7937
+ async goOut(ask, asker, publish) {
7938
+ const now = Date.now();
7939
+ try {
7940
+ if (publish) {
7941
+ await this.host.publish(asker, requestRecords(asker.seedB64, ask.link, {
7942
+ invite: ask.invite,
7943
+ name: ask.name,
7944
+ text: ask.text,
7945
+ issued: ask.issued,
7946
+ expires: ask.expires
7947
+ }));
7948
+ ask = {
7949
+ ...ask,
7950
+ publishedAt: now
7951
+ };
7952
+ }
7953
+ const slot = knockSlotIdentity(ask.link, knockSlotOf(ask.link, ask.key));
7954
+ for (let attempt = 0; attempt < 3; attempt++) {
7955
+ const knocks = readKnockSlot(ask.link, await this.host.resolve(slot.pubKeyZ32) ?? []);
7956
+ const stamp = ask.stamp;
7957
+ if (knocks.some((k) => k.key === ask.key && k.day === stamp.day && k.stamp === stamp.stamp)) break;
7958
+ if (attempt === 2) break;
7959
+ await this.host.publish(slot, knockSlotRecords(ask.link, mergeContactKnocks(knocks, {
7960
+ key: ask.key,
7961
+ day: stamp.day,
7962
+ stamp: stamp.stamp
7963
+ }, now)));
7964
+ await sleep(KNOCK_CHECK_MS);
7965
+ }
7966
+ ask = {
7967
+ ...ask,
7968
+ state: ask.state === "sending" ? "knocking" : ask.state,
7969
+ lookedAt: now
7970
+ };
7971
+ } catch {
7972
+ ask = {
7973
+ ...ask,
7974
+ lookedAt: now
7975
+ };
7976
+ }
7977
+ await this.put(ask);
7978
+ this.host.emit();
7979
+ }
7980
+ async look() {
7981
+ if (!this.host.online()) return;
7982
+ const now = Date.now();
7983
+ for (const ask of this.asks) {
7984
+ if (ask.state !== "sending" && ask.state !== "knocking" && ask.state !== "seen") continue;
7985
+ if (ask.expires <= now) {
7986
+ await this.put({
7987
+ ...ask,
7988
+ state: "ended"
7989
+ });
7990
+ this.host.emit();
7991
+ continue;
7992
+ }
7993
+ if (ask.state === "seen") continue;
7994
+ const pace = now - ask.issued < 6e5 ? ASK_FAST_MS : ASK_SLOW_MS;
7995
+ if (ask.lookedAt !== void 0 && now - ask.lookedAt < pace - SLACK_MS) continue;
7996
+ const record = readLinkRecord(await this.host.resolve(ask.link).catch(() => null) ?? []);
7997
+ if (record?.state === "off") {
7998
+ await this.put({
7999
+ ...ask,
8000
+ state: "off"
8001
+ });
8002
+ this.host.emit();
8003
+ continue;
8004
+ }
8005
+ if (record?.seen.includes(seenEntry(ask.link, ask.key))) {
8006
+ await this.put({
8007
+ ...ask,
8008
+ state: "seen"
8009
+ });
8010
+ this.host.emit();
8011
+ continue;
8012
+ }
8013
+ let next = ask;
8014
+ const day = contactDay(now);
8015
+ const bits = Math.max(record?.bits ?? 18, 18);
8016
+ if (!next.stamp || next.stamp.day < day - 1 || next.stamp.bits < bits) next = {
8017
+ ...next,
8018
+ stamp: {
8019
+ day,
8020
+ bits,
8021
+ stamp: await makeStampPaced(ask.link, ask.key, day, bits)
8022
+ }
8023
+ };
8024
+ await this.goOut(next, await this.identity(next), next.publishedAt === void 0 || now - next.publishedAt >= 36e5);
8025
+ }
8026
+ }
8027
+ schedule(ms) {
8028
+ if (!this.running) return;
8029
+ if (this.timer) clearTimeout(this.timer);
8030
+ this.timer = setTimeout(() => {
8031
+ this.timer = null;
8032
+ this.tick().catch(() => {}).finally(() => {
8033
+ if (!this.running || this.timer) return;
8034
+ if (this.asks.some((a) => a.state === "sending" || a.state === "knocking")) this.schedule(ASK_FAST_MS);
8035
+ else if (this.asks.some((a) => a.state === "seen")) this.schedule(ASK_SLOW_MS);
8036
+ });
8037
+ }, ms);
8038
+ }
8039
+ serial(work) {
8040
+ const run = this.queue.then(work, work);
8041
+ this.queue = run.catch(() => {});
8042
+ return run;
8043
+ }
8044
+ async seal(identity) {
8045
+ const deviceKey = newDeviceKey();
8046
+ return {
8047
+ sealed: await sealSeed(identity.seedB64, deviceKey),
8048
+ deviceKey
8049
+ };
8050
+ }
8051
+ async identity(ask) {
8052
+ return identityFromSeedB64(await unsealSeed(ask.seed.sealed, ask.seed.deviceKey));
8053
+ }
8054
+ async put(ask) {
8055
+ const next = [...this.asks.filter((a) => a.link !== ask.link), ask];
8056
+ await wrap((await store(STORES$1.settings, "readwrite")).put(next, CONTACT_ASKS_KEY));
8057
+ this.asks = next;
8058
+ }
8059
+ };
7349
8060
  function profileName(value) {
7350
8061
  if (typeof value !== "string" || value.length > 256) return;
7351
8062
  return value.replace(/[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/g, "").trim().slice(0, 64) || void 0;
@@ -9883,6 +10594,17 @@ function forwardKind(message) {
9883
10594
  if (message.file) return "file";
9884
10595
  return message.text.trim() ? "text" : { refused: "This message cannot be forwarded" };
9885
10596
  }
10597
+ /**
10598
+ * Why a chat did not get a forward, as its result says it: never "" (a result with an error is a failure, whatever it
10599
+ * says). A copy that found no room says so in the words the app knows however the storage put it: IndexedDB's
10600
+ * QuotaExceededError (an empty message in Chromium), the OPFS worker's (named so), Desktop's io error (the system's English).
10601
+ */
10602
+ function forwardError(error) {
10603
+ const message = (error instanceof Error ? error.message : String(error)).trim();
10604
+ if (/^No space left on device\b/.test(message)) return `Not enough space on this device (${message})`;
10605
+ const name = error?.name;
10606
+ return errorLine(error).trim() || (typeof name === "string" && name ? name : "Error");
10607
+ }
9886
10608
  /** How many bytes of a stored file are actually here: all of them once it arrived. */
9887
10609
  async function bytesHere(file) {
9888
10610
  if (file.blob) return file.blob.size;
@@ -10164,14 +10886,14 @@ var DEFAULT_APP_STORES = [{
10164
10886
  * entry is added with the pin of its bundle in apps/ui/src/assets/apps/built-in.json, which the build reads from the
10165
10887
  * app's own repository (apps/ui/scripts/built-in-apps.mjs); no bundle is kept in this repository.
10166
10888
  *
10167
- * Chess: Ghostly's own publisher key for it (fingerprint odcg w6wj w8dy nqop), version 2.3.0 (`sequence` 4), at the
10889
+ * Chess: Ghostly's own publisher key for it (fingerprint odcg w6wj w8dy nqop), version 2.3.1 (`sequence` 5), at the
10168
10890
  * commit of its repository that the official store lists.
10169
10891
  */
10170
10892
  var BUILT_IN_APPS = [{
10171
10893
  publisher: "odcgw6wjw8dynqop84r47jbjcdqossbgrfiejd367e14hgxmjcho",
10172
10894
  name: "chess",
10173
- digest: "EEnFmP0grTkTe_9qgapQsEEZzWB9FcpoimWkHnfecVs",
10174
- url: "https://cdn.jsdelivr.net/gh/MiguelMedeiros/ghostly-chess@c665ab194478145fd9b9a4777e6c0d1893969649/app.ghostlyapp",
10895
+ digest: "PnrjBRayGNbhfCkjPXmxLpjKYe2_m9Q65_hUPP8BHOc",
10896
+ url: "https://cdn.jsdelivr.net/gh/MiguelMedeiros/ghostly-chess@0cbf1acc8fd7a03848f4e2a68cbec8854767ceaa/app.ghostlyapp",
10175
10897
  title: "Chess",
10176
10898
  tagline: "Play chess with a contact, live in your chat"
10177
10899
  }];
@@ -10233,6 +10955,8 @@ var BUNDLES_KEPT = {
10233
10955
  };
10234
10956
  /** The first bytes of a bundle: magic, the manifest's length and the largest manifest. */
10235
10957
  var PEEK_BYTES = APP_BUNDLE_MAGIC.length + 4 + APP_BUNDLE_LIMITS.manifestBytes;
10958
+ /** What a peek reads first: the magic, the length and any usual manifest (about 1 KiB). A longer one is read to its end after. */
10959
+ var PEEK_FIRST_BYTES = 4096;
10236
10960
  var bundleId = (digest) => `app-${digest}`;
10237
10961
  /**
10238
10962
  * The key of a scope's counter row (`AppScopeCount` as JSON in `value`, `size` 0): an app's key is never empty
@@ -10398,6 +11122,13 @@ function peekAppManifest(bytes) {
10398
11122
  return null;
10399
11123
  }
10400
11124
  }
11125
+ /** Where a bundle's manifest ends, from its first bytes, or null when they are not a bundle's or name a manifest too long. */
11126
+ function peekManifestEnd(bytes) {
11127
+ const magic = utf8Encode(APP_BUNDLE_MAGIC);
11128
+ if (bytes.length < magic.length + 4 || !magic.every((b, i) => bytes[i] === b)) return null;
11129
+ const end = magic.length + 4 + new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).getUint32(magic.length);
11130
+ return end <= PEEK_BYTES ? end : null;
11131
+ }
10401
11132
  var Apps = class {
10402
11133
  host;
10403
11134
  staged = /* @__PURE__ */ new Map();
@@ -10856,6 +11587,23 @@ var Apps = class {
10856
11587
  return read;
10857
11588
  }
10858
11589
  /**
11590
+ * The manifest at the start of the bundle at `url`, unchecked (`peekAppManifest`): its first `PEEK_FIRST_BYTES`, and
11591
+ * only for a longer manifest a second read from the start to exactly its end (from the start: one file's bytes, even
11592
+ * when it changed between the two).
11593
+ */
11594
+ async peekAt(url) {
11595
+ const first = await this.host.fetch(url, {
11596
+ maxBytes: PEEK_FIRST_BYTES,
11597
+ peek: true
11598
+ });
11599
+ const end = peekManifestEnd(first);
11600
+ if (end === null || end <= first.length || first.length < PEEK_FIRST_BYTES) return peekAppManifest(first);
11601
+ return peekAppManifest(await this.host.fetch(url, {
11602
+ maxBytes: end,
11603
+ peek: true
11604
+ }));
11605
+ }
11606
+ /**
10859
11607
  * Reads the index at `url`. With the index held for this store, bytes equal to it (by digest) are the held index: its
10860
11608
  * signature and every revocation in it were checked when it was read, so only its expiry is taken again. Any other
10861
11609
  * index is read with a pause every few ms (the engine shares the page's thread, and an index holds up to 4096
@@ -11415,10 +12163,7 @@ var Apps = class {
11415
12163
  if (pinned === void 0) for (const url of (app.manifest.sources ?? []).filter(isAppFetchUrl)) {
11416
12164
  if (candidates.has(url)) continue;
11417
12165
  try {
11418
- const peek = peekAppManifest(await this.host.fetch(url, {
11419
- maxBytes: PEEK_BYTES,
11420
- peek: true
11421
- }));
12166
+ const peek = await this.peekAt(url);
11422
12167
  if (peek && newer(peek)) candidates.set(url, { holds: peek });
11423
12168
  } catch {}
11424
12169
  }
@@ -11430,10 +12175,7 @@ var Apps = class {
11430
12175
  if (want.peek) {
11431
12176
  let peek;
11432
12177
  try {
11433
- peek = peekAppManifest(await this.host.fetch(url, {
11434
- maxBytes: PEEK_BYTES,
11435
- peek: true
11436
- }));
12178
+ peek = await this.peekAt(url);
11437
12179
  } catch {
11438
12180
  continue;
11439
12181
  }
@@ -11589,10 +12331,7 @@ var Apps = class {
11589
12331
  if (app.store === void 0) urls.push(...app.manifest.sources ?? []);
11590
12332
  for (const url of new Set(urls.filter(isAppFetchUrl))) {
11591
12333
  try {
11592
- if (peekAppManifest(await this.host.fetch(url, {
11593
- maxBytes: PEEK_BYTES,
11594
- peek: true
11595
- }))?.digest !== app.digest) continue;
12334
+ if ((await this.peekAt(url))?.digest !== app.digest) continue;
11596
12335
  } catch {
11597
12336
  continue;
11598
12337
  }
@@ -19805,6 +20544,11 @@ var Outbox = class {
19805
20544
  get sameLink() {
19806
20545
  return !this.stopped && this.closed === this.began;
19807
20546
  }
20547
+ /** A check is scheduled for something queued or waiting: one that went out keeps its `waiting` entry (its backoff step), not its timer. */
20548
+ get scheduled() {
20549
+ for (const wait of this.waiting.values()) if (wait.timer) return true;
20550
+ return false;
20551
+ }
19808
20552
  /** One message as stored now: by its id when the store can, else picked out of the chat's history. */
19809
20553
  async one(id) {
19810
20554
  return this.store.get ? this.store.get(id) : (await this.store.read()).find((m) => m.id === id);
@@ -19855,12 +20599,6 @@ var Outbox = class {
19855
20599
  const unconfirmed = (m) => m.sender === "me" && m.via !== "hold" && m.delivery === "sent" && this.receipts.has(m.id);
19856
20600
  return messages.filter((m) => !this.busy.has(m.id) && (toGo(m) || this.file(m) || unconfirmed(m) && (opened !== void 0 && (this.sentUnder.get(m.id) ?? 0) < opened || !!this.resender.via && this.resender.via(m) !== m.via && this.resender.ready(m)))).sort(oldestFirst);
19857
20601
  }
19858
- /**
19859
- * Looks at each message, oldest first: it goes if the chat can carry it. The ones the chat could not carry are
19860
- * handed back, and asked once more at the moment a later one is found able to go (`catchUp`): a link says it is open
19861
- * a moment before it carries text, and a look that began in that moment sent the later messages and left the first
19862
- * ones behind.
19863
- */
19864
20602
  /** Whether this chat's waiting files go through this outbox (`Resender.sendFile`). */
19865
20603
  get sendsFiles() {
19866
20604
  return !!this.resender.sendFile;
@@ -19875,11 +20613,20 @@ var Outbox = class {
19875
20613
  return false;
19876
20614
  }
19877
20615
  if (passed) await this.catchUp(passed);
19878
- if (!this.sameLink) return true;
20616
+ if (!this.sameLink) {
20617
+ if (!this.stopped && !this.waiting.get(message.id)?.timer) this.schedule(message.id);
20618
+ return true;
20619
+ }
19879
20620
  await this.resender.sendFile(message).catch(() => {});
19880
20621
  this.forget(message.id);
19881
20622
  return true;
19882
20623
  }
20624
+ /**
20625
+ * Looks at each message, oldest first: it goes if the chat can carry it. The ones the chat could not carry are
20626
+ * handed back, and asked once more at the moment a later one is found able to go (`catchUp`): a link says it is open
20627
+ * a moment before it carries text, and a look that began in that moment sent the later messages and left the first
20628
+ * ones behind.
20629
+ */
19883
20630
  async inOrder(messages) {
19884
20631
  const passed = [];
19885
20632
  for (const message of messages) {
@@ -19974,15 +20721,16 @@ var Outbox = class {
19974
20721
  await this.store.update(id, "delivered");
19975
20722
  this.clear(id);
19976
20723
  this.forget(id);
19977
- if (this.waiting.size) this.flush().catch(() => {});
20724
+ if (this.scheduled) this.flush().catch(() => {});
19978
20725
  }
19979
20726
  /**
19980
20727
  * The chat can carry text again (its link came back, or the DHT path freed up): every queued message goes now,
19981
20728
  * oldest first. `reopened`: a new link, so what still awaits a receipt from the old one, or from the DHT, goes on it too
19982
- * (a receipt on the link also ends the DHT's own retries).
20729
+ * (a receipt on the link also ends the DHT's own retries). Bare, it looks only when a check is scheduled: a receipt,
20730
+ * and every read of the contact's DHT mailbox, call it.
19983
20731
  */
19984
20732
  flush({ reopened = false } = {}) {
19985
- if (this.stopped || !reopened && !this.waiting.size) return Promise.resolve();
20733
+ if (this.stopped || !reopened && !this.scheduled) return Promise.resolve();
19986
20734
  const opened = reopened ? ++this.opened : void 0;
19987
20735
  return this.turn(async () => {
19988
20736
  if (this.stopped) return;
@@ -19990,15 +20738,47 @@ var Outbox = class {
19990
20738
  await this.inOrder(this.due(await this.store.read(), opened));
19991
20739
  });
19992
20740
  }
19993
- /** A queued message's check came due: the ones written before it that have not gone are looked at first. */
20741
+ /**
20742
+ * A queued message's check came due: the ones written before it that have not gone are looked at first. The checks
20743
+ * of n texts written to an away contact come due a few milliseconds apart, and each read the chat and looked at
20744
+ * every older text again: n²/2 reads at each backoff step. The first look now takes up every check due by the next
20745
+ * few seconds too (`backoffMs[0]`), and what was written before them, and gives each its next one: they come due
20746
+ * together from then on, and the others find theirs taken.
20747
+ */
19994
20748
  async check(id) {
19995
20749
  if (this.stopped) return;
20750
+ if (this.waiting.get(id)?.timer || this.receipts.has(id)) return;
19996
20751
  const messages = await this.store.read(), message = messages.find((m) => m.id === id);
19997
20752
  if (!message || !(toGo(message) || this.file(message))) {
19998
20753
  await this.attempt(id);
19999
20754
  return;
20000
20755
  }
20001
- await this.inOrder(this.due(messages.filter((m) => m.timestamp <= message.timestamp)));
20756
+ const soon = this.now() + this.policy.backoffMs[0], taken = [];
20757
+ let until = message.timestamp;
20758
+ for (const m of messages) {
20759
+ const wait = this.waiting.get(m.id);
20760
+ if (m.id === id || !wait || this.busy.has(m.id) || !(toGo(m) || this.file(m)) || wait.timer && wait.at > soon) continue;
20761
+ if (wait.timer) {
20762
+ clearTimeout(wait.timer);
20763
+ wait.timer = void 0;
20764
+ wait.step++;
20765
+ }
20766
+ taken.push(m.id);
20767
+ if (m.timestamp > until) until = m.timestamp;
20768
+ }
20769
+ await this.inOrder(this.due(messages.filter((m) => m.timestamp <= until)));
20770
+ for (const other of taken) {
20771
+ const wait = this.waiting.get(other);
20772
+ if (!this.stopped && wait && !wait.timer && !this.busy.has(other) && !this.receipts.has(other)) this.schedule(other);
20773
+ }
20774
+ }
20775
+ /**
20776
+ * As read by the look in progress, it still waits: the chat cannot carry it, and it is not due to fail or to be
20777
+ * handed to store-and-forward. Nothing acts on it, so it is not read again.
20778
+ */
20779
+ waitsOn(message) {
20780
+ const wait = this.waiting.get(message.id);
20781
+ return !!wait && (message.delivery === "queued" || message.delivery === "waiting") && message.via !== "hold" && (message.resendUntil ?? Infinity) > this.now() && !this.resender.ready(message) && !(this.resender.divert && this.now() - wait.since >= this.policy.holdAfterMs);
20002
20782
  }
20003
20783
  /**
20004
20784
  * A queued or waiting message is looked at; one sent and awaiting its receipt goes again now, if the chat can carry
@@ -20006,7 +20786,7 @@ var Outbox = class {
20006
20786
  */
20007
20787
  async again(message, passed) {
20008
20788
  if (message.sender !== "me" || message.via === "hold") return true;
20009
- if (message.delivery === "queued" || message.delivery === "waiting") return this.attempt(message.id, passed);
20789
+ if (message.delivery === "queued" || message.delivery === "waiting") return this.attempt(message.id, passed, message);
20010
20790
  if (message.delivery !== "sent" || !this.receipts.has(message.id)) return true;
20011
20791
  this.clear(message.id);
20012
20792
  if (this.resender.ready(message)) {
@@ -20050,10 +20830,15 @@ var Outbox = class {
20050
20830
  /**
20051
20831
  * Looks at one queued message: sends it if the chat can carry it, hands it to store-and-forward, or waits more.
20052
20832
  * False when the chat could not carry it and it still waits. `passed`: older messages the chat could not carry a
20053
- * moment ago, which go first if it can now.
20833
+ * moment ago, which go first if it can now. `read`: its row as the look in progress read it; while it still waits
20834
+ * (`waitsOn`) it is not read again, else a look at n waiting texts read each of them once more.
20054
20835
  */
20055
- async attempt(id, passed) {
20836
+ async attempt(id, passed, read) {
20056
20837
  if (this.stopped || this.busy.has(id)) return true;
20838
+ if (read && this.waitsOn(read)) {
20839
+ if (!this.waiting.get(id)?.timer) this.schedule(id);
20840
+ return false;
20841
+ }
20057
20842
  const message = await this.one(id);
20058
20843
  if (!message || message.delivery !== "queued" && message.delivery !== "waiting" || message.via === "hold") {
20059
20844
  this.forget(id);
@@ -20105,10 +20890,12 @@ var Outbox = class {
20105
20890
  schedule(id) {
20106
20891
  const wait = this.waiting.get(id) ?? {
20107
20892
  step: 0,
20108
- since: this.now()
20893
+ since: this.now(),
20894
+ at: 0
20109
20895
  };
20110
20896
  if (wait.timer) clearTimeout(wait.timer);
20111
20897
  const delay = this.policy.backoffMs[Math.min(wait.step, this.policy.backoffMs.length - 1)];
20898
+ wait.at = this.now() + delay;
20112
20899
  wait.timer = setTimeout(() => {
20113
20900
  wait.timer = void 0;
20114
20901
  wait.step++;
@@ -20177,6 +20964,9 @@ var EditQueue = class {
20177
20964
  restoredAt = -Infinity;
20178
20965
  /** The rows known to have an edit on its way, each with when it was last told (`edited`), by `tick`. */
20179
20966
  known = /* @__PURE__ */ new Map();
20967
+ /** Of the known rows read: each one's wire id, and the DHT floor id of the edit it last sent (`dhtEditId`). A receipt finds its row by them. */
20968
+ wires = /* @__PURE__ */ new Map();
20969
+ dhtIds = /* @__PURE__ */ new Map();
20180
20970
  tick = 0;
20181
20971
  /** The chat's history was read for the rows with an edit on its way: `known` has them all, until the next session. */
20182
20972
  learnt = false;
@@ -20230,40 +21020,57 @@ var EditQueue = class {
20230
21020
  }
20231
21021
  /**
20232
21022
  * 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
20233
- * read one by, or nothing learnt yet in this session). Otherwise the known rows are read alone, and one that waits
20234
- * no more is forgotten, unless it was told again while it was read.
21023
+ * read them by, or nothing learnt yet in this session). Otherwise the known rows (`only`: those of them) are read
21024
+ * alone, in one read, and one that waits no more is forgotten, unless it was told again while it was read.
20235
21025
  */
20236
- async pending() {
21026
+ async pending(only) {
20237
21027
  const waits = (m) => m.sender === "me" && !!m.edit?.pending;
20238
- const row = this.deps.row;
20239
- if (!row || !this.learnt) {
21028
+ const read = this.deps.rows;
21029
+ if (!read || !this.learnt) {
20240
21030
  const all = await this.deps.read(), rows = all.filter(waits);
20241
- for (const m of rows) if (!this.known.has(m.id)) this.known.set(m.id, this.tick);
21031
+ for (const m of rows) {
21032
+ if (!this.known.has(m.id)) this.known.set(m.id, this.tick);
21033
+ if (m.wireId) this.wires.set(m.id, m.wireId);
21034
+ }
20242
21035
  this.learnt = true;
20243
21036
  return {
20244
21037
  rows,
20245
21038
  all
20246
21039
  };
20247
21040
  }
20248
- const asOf = this.tick, ids = [...this.known.keys()];
20249
- const read = await Promise.all(ids.map((id) => row(id)));
21041
+ const asOf = this.tick, ids = only ?? [...this.known.keys()];
20250
21042
  const rows = [];
20251
- read.forEach((m, i) => {
20252
- if (m && waits(m)) rows.push(m);
20253
- else if ((this.known.get(ids[i]) ?? 0) <= asOf) this.known.delete(ids[i]);
21043
+ (ids.length ? await read(ids) : []).forEach((m, i) => {
21044
+ if (m && waits(m)) {
21045
+ rows.push(m);
21046
+ if (m.wireId) this.wires.set(m.id, m.wireId);
21047
+ } else if ((this.known.get(ids[i]) ?? 0) <= asOf) {
21048
+ this.known.delete(ids[i]);
21049
+ this.wires.delete(ids[i]);
21050
+ this.dhtIds.delete(ids[i]);
21051
+ }
20254
21052
  });
20255
21053
  return { rows: rows.sort((a, b) => a.timestamp - b.timestamp) };
20256
21054
  }
21055
+ /**
21056
+ * The known rows a receipt may be for: the one `matches` finds among those read before, else those not read yet (none:
21057
+ * it names no edit on its way). Undefined: no rows to read them by, or nothing learnt yet, so all of them.
21058
+ */
21059
+ candidates(ids, matches) {
21060
+ if (!this.deps.rows || !this.learnt) return void 0;
21061
+ for (const [rowId, value] of ids) if (matches(value) && this.known.has(rowId)) return [rowId];
21062
+ return [...this.known.keys()].filter((rowId) => !ids.has(rowId));
21063
+ }
20257
21064
  /** The contact confirmed edit `seq` of my message with this wire id. */
20258
21065
  async received(wireId, seq) {
20259
- const row = (await this.pending()).rows.find((m) => m.wireId === wireId);
21066
+ const row = (await this.pending(this.candidates(this.wires, (id) => id === wireId))).rows.find((m) => m.wireId === wireId);
20260
21067
  if (!row?.edit?.pending || row.edit.seq > seq) return;
20261
21068
  this.sent.delete(row.id);
20262
21069
  await this.deps.settle(row.id, row.edit.seq);
20263
21070
  }
20264
21071
  /** The contact confirmed, on the DHT floor, the edit that went there under this id (`dhtEditId`). */
20265
21072
  async receivedOnDht(id) {
20266
- const row = (await this.pending()).rows.find((m) => m.wireId && dhtEditId(m.wireId, m.edit.seq) === id);
21073
+ const row = (await this.pending(this.candidates(this.dhtIds, (sent) => sent === id))).rows.find((m) => m.wireId && dhtEditId(m.wireId, m.edit.seq) === id);
20267
21074
  if (!row) return;
20268
21075
  this.sent.delete(row.id);
20269
21076
  await this.deps.settle(row.id, row.edit.seq);
@@ -20274,7 +21081,7 @@ var EditQueue = class {
20274
21081
  this.timer = void 0;
20275
21082
  }
20276
21083
  async run() {
20277
- if (this.stopped) return;
21084
+ if (this.stopped || !this.deps.ready()) return;
20278
21085
  const now = this.now();
20279
21086
  const said = (m) => this.sent.get(m.id)?.seq === m.edit.seq ? 1 : 0;
20280
21087
  const { rows, all } = await this.pending();
@@ -20330,6 +21137,7 @@ var EditQueue = class {
20330
21137
  ...message.card && { sc: message.card }
20331
21138
  }, message)) continue;
20332
21139
  if (edit.restore) this.restoredAt = now;
21140
+ this.dhtIds.set(message.id, dhtEditId(message.wireId, edit.seq));
20333
21141
  this.sent.set(message.id, {
20334
21142
  seq: edit.seq,
20335
21143
  at: now,
@@ -20980,6 +21788,15 @@ var db = {
20980
21788
  return wrap((await store(STORES$1.messages, "readonly")).get([linkId, id]));
20981
21789
  },
20982
21790
  /**
21791
+ * These rows of a chat, in the order asked (undefined where none is), in one transaction: in the CLI's store
21792
+ * (fake-indexeddb) each transaction waiting at once is checked against all the others, so one per row costs the square.
21793
+ */
21794
+ async getMessagesById(linkId, ids) {
21795
+ if (!ids.length) return [];
21796
+ const messages = await store(STORES$1.messages, "readonly");
21797
+ return Promise.all(ids.map((id) => wrap(messages.get([linkId, id]))));
21798
+ },
21799
+ /**
20983
21800
  * The newest `limit` messages of a chat before `before`, oldest first, in `getMessages`' order (time, then id), and
20984
21801
  * whether older ones remain. Reads those rows only: a long chat's first page costs what a short one's does.
20985
21802
  * `before`: a message's place (the page ends just before it), or a time (`id` left out: the page ends before it).
@@ -21513,6 +22330,12 @@ async function myPin(host, chat, messageId, remove, current, now = Date.now()) {
21513
22330
  * about a catch-up, whose answers end within the three minutes the app waits for the first.
21514
22331
  */
21515
22332
  var AUTHOR_ORDER_WINDOW_MS = 18e4;
22333
+ /**
22334
+ * While some of a member's messages are still on their way, how long that member's rows wait for the next one before
22335
+ * they are placed again anyway: longer than a catch-up takes to ask for its next 32 (`MESH_GAP_AGAIN_MS`, 10 s). Placed
22336
+ * at each page, the newest rows moved down again with every older page, up to 84 writes a row for 200 caught up.
22337
+ */
22338
+ var AUTHOR_ORDER_SETTLE_MS = 15e3;
21516
22339
  AUTHOR_ORDER_WINDOW_MS / 2;
21517
22340
  /** Whether `a` was written after `b` by the same member: a later epoch, or a higher number in the same one. */
21518
22341
  function after(a, b) {
@@ -21522,10 +22345,12 @@ function after(a, b) {
21522
22345
  * A member's messages in a private group in the order that member wrote them (WISP 400, requirement 10, and WISP 902
21523
22346
  * § Catch-up). A message is placed where it arrives. When one comes of a member whose later messages were placed here
21524
22347
  * moments ago (two catch-up answers raced: the author's own newest 32 and another member's hand-on of the older ones),
21525
- * those later ones are placed again below it, in the order they were written, and the lines that came after them before
21526
- * the older one (another member's, mine) go along below them, in their order: a reply to the newest stays below it. A
21527
- * row only ever moves down, so nobody else's lines change order. While messages of that member are still missing
21528
- * (`missing`), the rows wait for them, so each row moves about once in a whole catch-up.
22348
+ * it is stored just above the first of them where the history has room there (`above`): the author's newest stays
22349
+ * the last while the older pages come. Where it has none, those later ones are placed again below it, in the order
22350
+ * they were written, and the lines that came after them before the older one (another member's, mine) go along below
22351
+ * them, in their order: a reply to the newest stays below it. A row only ever moves down, so nobody else's lines change
22352
+ * order. While messages of that member are still missing (`missing`), the rows wait for them, so each row moves about
22353
+ * once in a whole catch-up.
21529
22354
  */
21530
22355
  var GroupAuthorOrder = class {
21531
22356
  host;
@@ -21533,6 +22358,7 @@ var GroupAuthorOrder = class {
21533
22358
  missing;
21534
22359
  /** Per group: the members' rows followed, in the order they are placed in the history. */
21535
22360
  rows = /* @__PURE__ */ new Map();
22361
+ came = 0;
21536
22362
  /** Per group: the members with a row placed above an older one of theirs, since when, and when their last row came. */
21537
22363
  due = /* @__PURE__ */ new Map();
21538
22364
  running = /* @__PURE__ */ new Set();
@@ -21541,14 +22367,37 @@ var GroupAuthorOrder = class {
21541
22367
  this.linkOf = linkOf;
21542
22368
  this.missing = missing;
21543
22369
  }
21544
- /** A member's message was stored in the group's history, new: `at` is when, on the engine's clock. */
21545
- placed(groupId, row) {
22370
+ /**
22371
+ * The row a member's message about to be stored goes just above: the first placed of those that member wrote after
22372
+ * it, when nobody else's came after that one (a hand-on of everyone's older messages brings them in the order they
22373
+ * were written: the message goes after those). None when nothing they wrote later was placed here, or another's
22374
+ * came since: it goes where it comes.
22375
+ */
22376
+ above(groupId, row) {
22377
+ const rows = this.rows.get(groupId) ?? [];
22378
+ if (rows.some((r) => r.id === row.id)) return void 0;
22379
+ const at = rows.findIndex((r) => r.sender === row.sender && after(r, row));
22380
+ return at >= 0 && rows.slice(at).every((r) => r.sender === row.sender) ? rows[at].id : void 0;
22381
+ }
22382
+ /**
22383
+ * A member's message was stored in the group's history, new: `at` is when, on the engine's clock. `above`: the row
22384
+ * it was stored just above, as `above` named it; else it went to the end.
22385
+ */
22386
+ placed(groupId, row, above) {
21546
22387
  if (!this.host.placeAgain) return;
21547
22388
  const rows = (this.rows.get(groupId) ?? []).filter((r) => r.at > row.at - AUTHOR_ORDER_WINDOW_MS).slice(-575);
21548
- rows.push(row);
22389
+ const at = above === void 0 ? -1 : rows.findIndex((r) => r.id === above);
22390
+ const followed = {
22391
+ ...row,
22392
+ n: ++this.came
22393
+ };
22394
+ if (at >= 0) rows.splice(at, 0, followed);
22395
+ else rows.push(followed);
21549
22396
  this.rows.set(groupId, rows);
21550
22397
  let due = this.due.get(groupId);
21551
- if (!due?.has(row.sender) && !rows.some((r) => r.sender === row.sender && after(r, row))) return;
22398
+ const first = rows.slice(0, at >= 0 ? at : -1).findIndex((r) => r.sender === row.sender && after(r, row));
22399
+ if (!due?.has(row.sender) && first < 0) return;
22400
+ if (first >= 0) for (const r of rows.slice(first)) r.at = Math.max(r.at, row.at);
21552
22401
  if (!due) this.due.set(groupId, due = /* @__PURE__ */ new Map());
21553
22402
  due.set(row.sender, {
21554
22403
  since: due.get(row.sender)?.since ?? row.at,
@@ -21591,21 +22440,22 @@ var GroupAuthorOrder = class {
21591
22440
  const newer = mine.filter((r) => !after(from, r)).sort((a, b) => after(a, b) ? 1 : after(b, a) ? -1 : 0);
21592
22441
  const ids = new Set(newer.map((r) => r.id));
21593
22442
  const first = rows.findIndex((r) => ids.has(r.id));
21594
- let end = rows.length;
21595
- for (let i = first + 1, top = rows[first]; i < rows.length; i++) {
21596
- if (rows[i].sender !== sender) continue;
21597
- if (after(top, rows[i])) {
21598
- end = i;
22443
+ let end = Infinity, top = rows[first];
22444
+ for (const r of rows.filter((r) => r.n > rows[first].n).sort((a, b) => a.n - b.n)) {
22445
+ if (r.sender !== sender) continue;
22446
+ if (after(top, r)) {
22447
+ end = r.n;
21599
22448
  break;
21600
22449
  }
21601
- top = rows[i];
22450
+ top = r;
21602
22451
  }
21603
- const followers = rows.slice(first, end).filter((r) => r.sender !== sender);
22452
+ const followers = rows.slice(first).filter((r) => r.sender !== sender && r.n < end);
21604
22453
  for (const r of followers) ids.add(r.id);
21605
22454
  const moved = [...newer, ...followers];
21606
22455
  this.rows.set(groupId, [...rows.filter((r) => !ids.has(r.id)), ...moved.map((r) => ({
21607
22456
  ...r,
21608
- at: Math.max(r.at, now)
22457
+ at: Math.max(r.at, now),
22458
+ n: ++this.came
21609
22459
  }))]);
21610
22460
  await this.host.placeAgain(this.linkOf(groupId), moved.map((r) => r.id)).catch(() => {});
21611
22461
  }
@@ -21615,8 +22465,8 @@ var GroupAuthorOrder = class {
21615
22465
  }
21616
22466
  }
21617
22467
  };
21618
- //#endregion
21619
- //#region ../browser/src/engine/groupLineOrder.ts
22468
+ /** How long the lines wait at most while older messages keep coming: one move every so often in a long catch-up. */
22469
+ var LINE_ORDER_WAIT_MAX_MS = AUTHOR_ORDER_SETTLE_MS;
21620
22470
  /**
21621
22471
  * The lines of a group's history ("… joined", "… is no longer a member") among the messages of their moment, for a
21622
22472
  * member back from away (WISP 902 § Catch-up). A message sealed under an epoch was written before its author knew of
@@ -21625,30 +22475,39 @@ var GroupAuthorOrder = class {
21625
22475
  * its epoch) and, in a private group, the answers of several members race, so the messages of the epoch before can come
21626
22476
  * after the line. Then the line, and the rows after it written knowing it, are placed again below them, in their order;
21627
22477
  * no clock is read. A row only ever moves down, and only rows placed here in the last few minutes, so a live
21628
- * conversation read long ago never moves. Moves made meanwhile go in one turn: a row moves about once a catch-up page.
22478
+ * conversation read long ago never moves. The lines wait for the rest of the older messages (`tick`), and while
22479
+ * messages of an epoch before are still on their way (`onTheirWay`) the rows wait for them, as `GroupAuthorOrder`'s do:
22480
+ * a row moves about once a catch-up.
21629
22481
  */
21630
22482
  var GroupLineOrder = class {
21631
22483
  host;
22484
+ onTheirWay;
21632
22485
  /** Per history: the rows followed, in the order they are placed in it. */
21633
22486
  rows = /* @__PURE__ */ new Map();
22487
+ /** Per history with rows out of order: since when, and when the last one came. */
22488
+ waiting = /* @__PURE__ */ new Map();
21634
22489
  due = /* @__PURE__ */ new Set();
21635
22490
  running = /* @__PURE__ */ new Set();
21636
- constructor(host) {
22491
+ constructor(host, onTheirWay = () => false) {
21637
22492
  this.host = host;
22493
+ this.onTheirWay = onTheirWay;
21638
22494
  }
21639
22495
  /**
21640
22496
  * A row is being stored, a line of the history or a message sealed under this epoch, and takes its place now: `at` is
21641
22497
  * when, on the engine's clock. Call what this returns with whether it was new: a message below a line of a later
21642
- * epoch takes the line (and the rows after it written knowing it) below it.
22498
+ * epoch takes the line (and the rows after it written knowing it) below it. `above`: the row it is stored just above
22499
+ * (`GroupAuthorOrder.above`); else it goes to the end.
21643
22500
  */
21644
- placing(linkId, row, at) {
22501
+ placing(linkId, row, at, above) {
21645
22502
  if (!this.host.placeAgain) return () => {};
21646
22503
  const followed = {
21647
22504
  ...row,
21648
22505
  at
21649
22506
  };
21650
22507
  const rows = (this.rows.get(linkId) ?? []).filter((r) => r.at > at - AUTHOR_ORDER_WINDOW_MS).slice(-575);
21651
- rows.push(followed);
22508
+ const index = above === void 0 ? -1 : rows.findIndex((r) => r.id === above);
22509
+ if (index >= 0) rows.splice(index, 0, followed);
22510
+ else rows.push(followed);
21652
22511
  this.rows.set(linkId, rows);
21653
22512
  return (stored) => {
21654
22513
  const now = this.rows.get(linkId);
@@ -21656,23 +22515,46 @@ var GroupLineOrder = class {
21656
22515
  if (now) this.rows.set(linkId, now.filter((r) => r !== followed));
21657
22516
  return;
21658
22517
  }
21659
- if (!followed.line && now?.some((r) => r.line && r.epoch > followed.epoch && now.indexOf(r) < now.indexOf(followed))) this.run(linkId);
22518
+ if (followed.line || !now) return;
22519
+ const line = now.findIndex((r) => r.line && r.epoch > followed.epoch);
22520
+ if (line < 0 || line > now.indexOf(followed)) return;
22521
+ for (const r of now.slice(line)) r.at = Math.max(r.at, at);
22522
+ this.wait(linkId, at);
21660
22523
  };
21661
22524
  }
21662
- /** These rows were placed again below everything (`GroupAuthorOrder`), in this order. */
21663
- moved(linkId, ids) {
22525
+ /** These rows were placed again below everything (`GroupAuthorOrder`), in this order, at `at` on the engine's clock. */
22526
+ moved(linkId, ids, at) {
21664
22527
  const rows = this.rows.get(linkId);
21665
22528
  if (!rows) return;
21666
22529
  const moved = ids.map((id) => rows.find((r) => r.id === id)).filter((r) => !!r);
21667
22530
  if (!moved.length) return;
21668
22531
  this.rows.set(linkId, [...rows.filter((r) => !moved.includes(r)), ...moved]);
21669
- if (moved.some((r) => !r.line)) this.run(linkId);
22532
+ if (moved.some((r) => !r.line)) this.wait(linkId, at);
22533
+ }
22534
+ /**
22535
+ * Time passed: histories no older message came to for a moment, and nothing more is on its way for, or that waited
22536
+ * long enough, are put in order.
22537
+ */
22538
+ tick(now) {
22539
+ for (const [linkId, { since, last }] of this.waiting) {
22540
+ if (now - last < 3e3 && now - since < LINE_ORDER_WAIT_MAX_MS) continue;
22541
+ if (now - last < 15e3 && now - since < 9e4 && this.onTheirWay(linkId)) continue;
22542
+ this.waiting.delete(linkId);
22543
+ this.run(linkId);
22544
+ }
21670
22545
  }
21671
22546
  /** The group's history is gone (left or forgotten). */
21672
22547
  forget(linkId) {
21673
22548
  this.rows.delete(linkId);
22549
+ this.waiting.delete(linkId);
21674
22550
  this.due.delete(linkId);
21675
22551
  }
22552
+ wait(linkId, at) {
22553
+ this.waiting.set(linkId, {
22554
+ since: this.waiting.get(linkId)?.since ?? at,
22555
+ last: at
22556
+ });
22557
+ }
21676
22558
  /** The history's rows put in order; what comes while a move is written goes in the next turn. */
21677
22559
  run(linkId) {
21678
22560
  this.due.add(linkId);
@@ -21688,8 +22570,9 @@ var GroupLineOrder = class {
21688
22570
  if (above < 0) order.push(row);
21689
22571
  else order.splice(above, 0, row);
21690
22572
  }
22573
+ const place = new Map(rows.map((r, i) => [r, i]));
21691
22574
  let from = 0;
21692
- while (from < rows.length && rows[from] === order[from]) from++;
22575
+ while (from < order.length && (from === 0 || place.get(order[from]) > place.get(order[from - 1]))) from++;
21693
22576
  if (from === rows.length) continue;
21694
22577
  const last = rows[rows.length - 1].at;
21695
22578
  for (const r of order.slice(from)) r.at = Math.max(r.at, last);
@@ -21875,6 +22758,14 @@ var CUT_OFF_STEP_UP_MS = 15e3;
21875
22758
  /** A lobby request this recent is from a member still looking fast for my side of the edge: mine opens expecting it. */
21876
22759
  var LOBBY_EXPECT_MS = 3e4;
21877
22760
  /**
22761
+ * A member whose edge was down `memberGoneMs` is dropped by its hub, and its edge closed: its app closed, and is often
22762
+ * opened again within minutes, when it dials an edge my side no longer has and asks in my lobby. So my lobby is read at
22763
+ * the busy pace this long after, as a 1:1 chat watches a contact that went away (`WATCH_PEER_MS`). Read every half minute,
22764
+ * a web member back two minutes after it closed got the lines written meanwhile 30-45 s after it opened, while a 1:1
22765
+ * text from the same bot came in seconds (bug hunt 1013-2254, 2026-10-14).
22766
+ */
22767
+ var MEMBER_BACK_MS = WATCH_PEER_MS;
22768
+ /**
21878
22769
  * A lobby is one record every member asking a hub writes, each reading it and writing it back with its own request: two
21879
22770
  * members writing at once (both cut off by the same leave, in the same second) each write what they read, and the later
21880
22771
  * write drops the earlier request. So a member reads the lobby back this long after its write (plus up to `LOBBY_CHECK_JITTER_MS`, so
@@ -22335,10 +23226,10 @@ var Communities = class {
22335
23226
  await this.require(groupId).rotate();
22336
23227
  }
22337
23228
  async setPicture(groupId, picture) {
22338
- await this.require(groupId).setPicture(picture);
23229
+ await this.require(groupId).setPicture(picture, this.now());
22339
23230
  }
22340
23231
  async rename(groupId, name) {
22341
- await this.require(groupId).rename(name);
23232
+ await this.require(groupId).rename(name, this.now());
22342
23233
  }
22343
23234
  /** A new link (the old one reaches nobody), or none. */
22344
23235
  async replaceLink(groupId, off = false) {
@@ -22401,6 +23292,7 @@ var Communities = class {
22401
23292
  /** Everything timed: beacon, hub election, lobbies, knocks, leaves, edges. The engine calls it every second or so. */
22402
23293
  async tick(now = Date.now()) {
22403
23294
  this.time = now;
23295
+ this.lineOrder.tick(now);
22404
23296
  const active = [...this.live.values()].filter((l) => l.session.status === "active");
22405
23297
  this.sharing = {
22406
23298
  groups: Math.max(1, active.length),
@@ -22534,7 +23426,7 @@ var Communities = class {
22534
23426
  const busy = this.admitting(groupId, live, now);
22535
23427
  if (s.state.pendingLeaves.some((r) => r.s !== me && this.recentHub(live, r.s))) live.lobbyBusyUntil = Math.max(live.lobbyBusyUntil, now + this.timings.lobbyBusyMs);
22536
23428
  if (this.hubDropped(groupId, live, others, now)) live.lobbyBusyUntil = Math.max(live.lobbyBusyUntil, now + this.timings.lobbyBusyMs);
22537
- const lobbyEvery = now < live.lobbyBusyUntil && !busy ? this.timings.lobbyPollMs : this.timings.lobbyIdlePollMs;
23429
+ const lobbyEvery = (now < live.lobbyBusyUntil || now < (live.memberBackUntil ?? 0)) && !busy ? this.timings.lobbyPollMs : this.timings.lobbyIdlePollMs;
22538
23430
  if (now - live.lastLobbyPoll >= lobbyEvery) {
22539
23431
  live.lastLobbyPoll = now;
22540
23432
  await this.pollLobby(groupId, live, now);
@@ -22578,6 +23470,9 @@ var Communities = class {
22578
23470
  }
22579
23471
  let kept = live.myHubs.filter((key) => (fresh.has(key) || this.recentHub(live, key)) && !live.hubsAvoided.has(key));
22580
23472
  kept = kept.slice(0, COMMUNITY_TOPOLOGY.hubsPerMember);
23473
+ if (!kept.length) {
23474
+ for (const key of live.restored.keys()) if (kept.length < COMMUNITY_TOPOLOGY.hubsPerMember && !live.hubsAvoided.has(key) && others.some((h) => h.key === key && h.load < COMMUNITY_TOPOLOGY.hubCapacity)) kept.push(key);
23475
+ }
22581
23476
  const picked = pickHubs(me, others, now, new Set(live.hubsAvoided.keys()));
22582
23477
  const bare = ![...edges.values()].some((id) => this.host.linkReady(id, 2));
22583
23478
  for (const key of picked) if (kept.length < Math.max(1, picked.length) && !kept.includes(key)) {
@@ -22676,7 +23571,11 @@ var Communities = class {
22676
23571
  const id = edges.get(key);
22677
23572
  if (this.gone(live, key)) live.members.delete(key);
22678
23573
  else if (id && this.host.linkReady(id, 2)) live.members.set(key, now);
22679
- else if (now - since > this.timings.memberGoneMs || live.session.wasRemoved(key)) live.members.delete(key);
23574
+ else if (live.session.wasRemoved(key)) live.members.delete(key);
23575
+ else if (now - since > this.timings.memberGoneMs) {
23576
+ live.members.delete(key);
23577
+ live.memberBackUntil = now + MEMBER_BACK_MS;
23578
+ }
22680
23579
  }
22681
23580
  this.shed(groupId, live, now);
22682
23581
  return live.members.size;
@@ -22729,7 +23628,8 @@ var Communities = class {
22729
23628
  async readBeacon(groupId, live, now) {
22730
23629
  live.lastBeaconRead = now;
22731
23630
  const keys = beaconKeys(live.session.state.rv, groupId);
22732
- const records = await this.host.resolve(keys.identity.pubKeyZ32, true, this.doorBusy(groupId, live, now)).catch(() => void 0);
23631
+ const back = !live.beaconAt && this.wayBack(groupId, live);
23632
+ const records = await this.host.resolve(keys.identity.pubKeyZ32, !back, !back && this.doorBusy(groupId, live, now), back).catch(() => void 0);
22733
23633
  if (records === void 0) {
22734
23634
  live.beaconFailedAt = now;
22735
23635
  live.beaconAskedAt ||= now;
@@ -22762,9 +23662,10 @@ var Communities = class {
22762
23662
  async publishBeacon(groupId, live, now, listed) {
22763
23663
  live.lastBeaconTry = now;
22764
23664
  const keys = beaconKeys(live.session.state.rv, groupId);
23665
+ const back = live.hub && this.wayBack(groupId, live);
22765
23666
  let existing = live.beacon, head = live.head;
22766
23667
  if (live.lastBeaconRead !== now || live.beaconFailedAt === now) {
22767
- const read = await this.host.resolve(keys.identity.pubKeyZ32, true, live.hub).catch(() => void 0);
23668
+ const read = await this.host.resolve(keys.identity.pubKeyZ32, !back, live.hub && !back, back).catch(() => void 0);
22768
23669
  if (read === void 0 && live.session.roster.length > 1) throw new Error("The beacon could not be read; not published");
22769
23670
  const records = read ?? [];
22770
23671
  existing = live.hubClocks.inMyTime(readBeacon(keys, records), now);
@@ -22777,7 +23678,7 @@ var Communities = class {
22777
23678
  since: live.hubSince || now
22778
23679
  } : null, now, (key) => !this.gone(live, key));
22779
23680
  const newest = newerHead(head, this.newestFrame(live), now);
22780
- await this.host.publish(keys.identity, beaconRecords(keys, live.hubClocks.asWritten(hubs), newest), true, live.hub);
23681
+ await this.host.publish(keys.identity, beaconRecords(keys, live.hubClocks.asWritten(hubs), newest), !back, live.hub && !back, back);
22781
23682
  live.beacon = hubs;
22782
23683
  live.head = newest;
22783
23684
  this.noteHubs(live, now);
@@ -22909,12 +23810,13 @@ var Communities = class {
22909
23810
  /** A member asks a hub for an edge: its key in the hub's lobby, and its side of the edge started. */
22910
23811
  async askHub(groupId, live, hub, now) {
22911
23812
  const keys = lobbyKeys(live.session.state.rv, groupId, hub);
22912
- const existing = readLobby(keys, await this.host.resolve(keys.identity.pubKeyZ32, true, true) ?? []);
23813
+ const back = this.wayBack(groupId, live);
23814
+ const existing = readLobby(keys, await this.host.resolve(keys.identity.pubKeyZ32, !back, !back, back) ?? []);
22913
23815
  const theirs = now - live.hubClocks.ahead(hub);
22914
23816
  await this.host.publish(keys.identity, lobbyRecords(keys, mergeLobby(existing, {
22915
23817
  key: live.session.myKey,
22916
23818
  ts: theirs
22917
- }, theirs)), true, true);
23819
+ }, theirs)), !back, !back, back);
22918
23820
  (live.lobbyChecks ??= /* @__PURE__ */ new Map()).set(hub, {
22919
23821
  at: now + LOBBY_CHECK_MS + Math.floor(this.random() * LOBBY_CHECK_JITTER_MS),
22920
23822
  ts: Math.floor(theirs / 1e3) * 1e3
@@ -22937,7 +23839,8 @@ var Communities = class {
22937
23839
  }
22938
23840
  async pollLobby(groupId, live, now) {
22939
23841
  const keys = lobbyKeys(live.session.state.rv, groupId, live.session.myKey);
22940
- const entries = readLobby(keys, await this.host.resolve(keys.identity.pubKeyZ32, true, this.doorBusy(groupId, live, now)).catch(() => null) ?? []);
23842
+ const back = this.wayBack(groupId, live);
23843
+ const entries = readLobby(keys, await this.host.resolve(keys.identity.pubKeyZ32, !back, !back && this.doorBusy(groupId, live, now), back).catch(() => null) ?? []);
22941
23844
  for (const { key, ts } of entries) {
22942
23845
  if (now - ts > COMMUNITY_TOPOLOGY.lobbyFreshMs || key === live.session.myKey) continue;
22943
23846
  if (live.session.wasRemoved(key)) {
@@ -23038,6 +23941,34 @@ var Communities = class {
23038
23941
  return live.hub && (now < live.warmUntil || now < live.crowdUntil || now < live.lobbyBusyUntil || this.admitting(groupId, live, now));
23039
23942
  }
23040
23943
  /**
23944
+ * A member with no edge up in this community: its first reading of the beacon (who the hubs are) and its request in a
23945
+ * hub's lobby are its way back, and go within the relays' allowance for it (`PkarrRequestOptions.back`), not as
23946
+ * background looks. Those wait for every link that signals and every chat's look: a web app opened with 30 chats whose
23947
+ * contacts were online read none of its 5 communities' beacons in 3 minutes, dropped the edges of its last run at 60 s,
23948
+ * and got no community line, neither those written while it was closed nor new ones (bug hunt, 2026-10-13). Nor as a
23949
+ * group edge's signaling: the edges of its 15 groups, all offering at once, spent that allowance in the first second,
23950
+ * and the beacons were read a minute later or not at all (bug hunt, 2026-10-14). Bounded: the reading is asked again
23951
+ * only when refused, and a hub that does not take me is given up after its wait (`hubWaitMs`).
23952
+ *
23953
+ * A hub with no edge up too (an app back from a restart steps up at once where it was one, before any edge is up): its
23954
+ * beacon entry, read and written, and its reading of its lobby. As background looks, a daemon back as the hub of its 5
23955
+ * communities while its 30 chats signaled got back into them one at a time, 4 to 10 minutes, one not at all in 16: its
23956
+ * entry went unwritten, the other hubs took it for gone (`hubDead`) and its members asked them (bug hunt, 2026-10-14).
23957
+ * Not a hub alone in the beacon: nobody carries the group without it, and its door's looks stay background ones.
23958
+ *
23959
+ * Alone as my last reading of the beacon said, and only while that reading is fresh. One that aged out says nothing of
23960
+ * now: judged by my clock against it, every hub it listed went stale, and I was alone. An app back from a restart as a
23961
+ * member of a community whose hubs did not list it read the beacon once (its way back); its edge stayed down while its
23962
+ * other links took its relays' budget, its reading aged out, it stepped up as a hub "alone", and its readings and its
23963
+ * entry went as background looks the relays held: it never read the beacon again, nobody listed it, and it was not
23964
+ * back in that community in 10 minutes (bug hunt, 2026-10-17).
23965
+ */
23966
+ wayBack(groupId, live) {
23967
+ const read = live.beaconAt, now = this.now();
23968
+ if (live.hub && now - read < COMMUNITY_TOPOLOGY.beaconFreshMs && !freshHubs(this.hubs(live), read).some((h) => h.key !== live.session.myKey)) return false;
23969
+ return ![...this.host.edges(groupId).values()].some((id) => this.host.linkReady(id, 2));
23970
+ }
23971
+ /**
23041
23972
  * Which knock records to read now (none, most of the time): every one at once the first time
23042
23973
  * (a hub just elected, an app back, a change of door), since a knock left while nobody was at the
23043
23974
  * door may be in any; then the door the bell, and the others in turn; the other hubs one in turn, slowly.
@@ -23504,6 +24435,7 @@ var Communities = class {
23504
24435
  noteCame(this.stored.get(id), came, m.sender !== session.myKey, mentioned);
23505
24436
  },
23506
24437
  app: (m) => this.deliver(id, () => m.frame.t === "group-have" ? this.files?.communityApp(id, m.sender, m.frame) : this.host.communityApp?.(id, m.sender, m.frame)),
24438
+ lasting: (m) => m.frame.t === "pin" && m.sender === session.admin ? "pin" : void 0,
23507
24439
  pair: (m) => this.deliver(id, () => this.host.communityPair?.(id, m.sender, m.payload)),
23508
24440
  changed: () => {
23509
24441
  this.membershipChanged(id);
@@ -24751,6 +25683,8 @@ var Groups = class {
24751
25683
  /** Admin side: entry sessions of a member key that timed out in a row, and when the last did. */
24752
25684
  entryTimeouts = /* @__PURE__ */ new Map();
24753
25685
  lastPoll = /* @__PURE__ */ new Map();
25686
+ /** Admin side: when a link nobody is using was last read. Those links share one `pollMs` between them, read in turn. */
25687
+ lastQuietPoll = 0;
24754
25688
  /** Admin side: until when a group's link is looked at the warm pace. */
24755
25689
  warmUntil = /* @__PURE__ */ new Map();
24756
25690
  lastKnock = /* @__PURE__ */ new Map();
@@ -24781,6 +25715,12 @@ var Groups = class {
24781
25715
  * whom, and when it was asked. Their sync goes once that answer has come.
24782
25716
  */
24783
25717
  syncHeld = /* @__PURE__ */ new Map();
25718
+ /**
25719
+ * Per mesh group, the members told where I am since their edge came up (`edgeReady`), forgotten when a tick sees it
25720
+ * down. One whose edge is up but not told yet opened in the same moment as the one being told: it is asked of that
25721
+ * one, as if its edge were still down (`askOf`), and no gossip turn or ask for gaps goes to it before it is told.
25722
+ */
25723
+ told = /* @__PURE__ */ new Map();
24784
25724
  /** Per mesh group and member, when a message last came over its edge (its own, or one it handed on). */
24785
25725
  lastHanded = /* @__PURE__ */ new Map();
24786
25726
  /** Admin side, per group and member let in: when its welcome was last sent again, over its chat or the link (`REWELCOME_MS`). */
@@ -24846,16 +25786,16 @@ var Groups = class {
24846
25786
  }
24847
25787
  }, hubTimings);
24848
25788
  this.typings = new GroupTypings(host);
24849
- this.lineOrder = new GroupLineOrder(host);
25789
+ this.lineOrder = new GroupLineOrder(host, (linkId) => {
25790
+ const groupId = linkId.slice(MESSAGE_LINK("").length), session = this.sessions.get(groupId);
25791
+ return !!session && [...session.lacking().keys()].some((sender) => this.onTheirWay(groupId, sender, session.epoch));
25792
+ });
24850
25793
  const placeAgain = host.placeAgain && (async (linkId, ids) => {
24851
25794
  const done = host.placeAgain(linkId, ids);
24852
- this.lineOrder.moved(linkId, ids);
25795
+ this.lineOrder.moved(linkId, ids, this.now());
24853
25796
  await done;
24854
25797
  });
24855
- this.authorOrder = new GroupAuthorOrder({ placeAgain }, MESSAGE_LINK, (groupId, sender) => {
24856
- const session = this.sessions.get(groupId);
24857
- return !!session && session.missing(sender, this.gapsGone.get(groupId)?.get(sender)?.[session.epoch]) > 0;
24858
- });
25798
+ this.authorOrder = new GroupAuthorOrder({ placeAgain }, MESSAGE_LINK, (groupId, sender) => this.onTheirWay(groupId, sender));
24859
25799
  this.signals = new MeshSignals(host, {
24860
25800
  session: (id) => this.sessions.get(id),
24861
25801
  stored: (id) => this.stored.get(id),
@@ -25107,10 +26047,11 @@ var Groups = class {
25107
26047
  if (this.isCommunity(groupId)) throw new Error("Share the group's link with them: anyone who opens it joins");
25108
26048
  const session = this.session(groupId);
25109
26049
  if (!session.isAdmin) throw new Error("Only the admin can invite");
26050
+ if (this.host.adminWork?.(groupId) === false) throw new Error(GROUP_ADMIN_OFF_ERROR);
25110
26051
  const group = this.stored.get(groupId);
25111
26052
  if (Object.entries(group.contacts ?? {}).some(([key, id]) => id === linkId && rosterHas(session.roster, key))) throw new Error("This contact is already a member");
25112
26053
  if (session.roster.length + (this.invited.get(groupId)?.size ?? 0) >= GROUP_MEMBER_CAP.max) throw new Error(`A group holds ${GROUP_MEMBER_CAP.max} members at most`);
25113
- if (!this.host.linkReady(linkId)) throw new Error("Connect to this contact first. Their app needs groups (an updated Ghostly).");
26054
+ if (!this.host.linkReady(linkId)) throw new Error(this.host.linkOpen?.(linkId) === false ? "This contact is not connected right now. Invite them when they are online." : "Their app does not take part in groups: they need an updated Ghostly first.");
25114
26055
  const blocked = this.growthBlocked(group, session, session.roster.length + (this.invited.get(groupId)?.size ?? 0) + 1, linkId);
25115
26056
  if (blocked) throw new Error(blocked);
25116
26057
  this.host.sendOnLink(linkId, session.inviteFrame());
@@ -25637,6 +26578,7 @@ var Groups = class {
25637
26578
  this.lingering.delete(groupId);
25638
26579
  this.relayAsked.delete(groupId);
25639
26580
  this.syncHeld.delete(groupId);
26581
+ this.told.delete(groupId);
25640
26582
  this.lastHanded.delete(groupId);
25641
26583
  this.rewelcomed.delete(groupId);
25642
26584
  this.hubs.forget(groupId);
@@ -25752,6 +26694,8 @@ var Groups = class {
25752
26694
  this.ticking = true;
25753
26695
  try {
25754
26696
  this.authorOrder.tick(now);
26697
+ this.lineOrder.tick(now);
26698
+ const quiet = [];
25755
26699
  for (const group of [...this.stored.values()]) {
25756
26700
  if (group.left) {
25757
26701
  if (now - group.left.at > LEFT_KEPT_MS) await this.forget(group.id);
@@ -25793,12 +26737,25 @@ var Groups = class {
25793
26737
  const linkId = this.host.entries(group.id).get(key);
25794
26738
  if (linkId) await this.host.closeEdge(linkId);
25795
26739
  }
25796
- const every = now < (this.warmUntil.get(group.id) ?? 0) ? this.timings.warmPollMs : this.timings.pollMs;
25797
- if (!((pending?.size ?? 0) >= MAX_PENDING_ENTRIES || session.roster.length + (pending?.size ?? 0) >= GROUP_MEMBER_CAP.max) && now - (this.lastPoll.get(group.id) ?? 0) >= every) {
26740
+ const busy = (pending?.size ?? 0) >= MAX_PENDING_ENTRIES || session.roster.length + (pending?.size ?? 0) >= GROUP_MEMBER_CAP.max;
26741
+ const since = now - (this.lastPoll.get(group.id) ?? 0);
26742
+ if (busy) continue;
26743
+ if (now >= (this.warmUntil.get(group.id) ?? 0)) {
26744
+ if (since >= this.timings.pollMs) quiet.push(group.id);
26745
+ } else if (since >= this.timings.warmPollMs) {
25798
26746
  this.lastPoll.set(group.id, now);
25799
26747
  await this.answerKnocks(group, session, now).catch(() => {});
25800
26748
  }
25801
26749
  }
26750
+ if (quiet.length && now - this.lastQuietPoll >= this.timings.pollMs) {
26751
+ const groupId = quiet.reduce((a, b) => (this.lastPoll.get(b) ?? 0) < (this.lastPoll.get(a) ?? 0) ? b : a);
26752
+ const group = this.stored.get(groupId), session = this.sessions.get(groupId);
26753
+ if (group?.entry && !group.left && session?.isAdmin) {
26754
+ this.lastQuietPoll = now;
26755
+ this.lastPoll.set(groupId, now);
26756
+ await this.answerKnocks(group, session, now).catch(() => {});
26757
+ }
26758
+ }
25802
26759
  for (const [key, until] of this.refused) if (until <= now) this.refused.delete(key);
25803
26760
  for (const [key, { at }] of this.entryTimeouts) if (now - at > 36e5) this.entryTimeouts.delete(key);
25804
26761
  for (const [groupId, kept] of this.lingering) if (now >= kept.until) await this.endLinger(groupId);
@@ -25902,7 +26859,16 @@ var Groups = class {
25902
26859
  /** The member at the other end of an entry session (the joiner, on the admin's side) or of a chat an invitation went over. */
25903
26860
  linkMember(groupId, linkId) {
25904
26861
  for (const [peer, id] of this.host.entries(groupId)) if (id === linkId) return peer;
25905
- return Object.entries(this.stored.get(groupId)?.contacts ?? {}).find(([, id]) => id === linkId)?.[0];
26862
+ return this.chatMember(groupId, linkId);
26863
+ }
26864
+ /**
26865
+ * The member a chat an invitation went over is: one removed and invited back over the same chat has its old key on it
26866
+ * too (`contacts` keeps it for the removal notice), so the key in the roster, else the newest.
26867
+ */
26868
+ chatMember(groupId, linkId) {
26869
+ const keys = Object.entries(this.stored.get(groupId)?.contacts ?? {}).filter(([, id]) => id === linkId).map(([key]) => key);
26870
+ const roster = this.sessions.get(groupId)?.roster;
26871
+ return roster && keys.find((key) => rosterHas(roster, key)) || keys[keys.length - 1];
25906
26872
  }
25907
26873
  /**
25908
26874
  * My packet for the edge to `to`, handed to links that reach that member or may pass it on (WISP 902 · Group Mesh
@@ -26295,8 +27261,7 @@ var Groups = class {
26295
27261
  }
26296
27262
  case "group-leave": {
26297
27263
  const session = this.sessions.get(g);
26298
- const group = this.stored.get(g);
26299
- const member = Object.entries(group?.contacts ?? {}).find(([, id]) => id === linkId)?.[0];
27264
+ const member = this.chatMember(g, linkId);
26300
27265
  if (!session?.isAdmin || !member || member === session.myKey) return;
26301
27266
  if (rosterHas(session.roster, member)) await session.remove(member, Date.now(), { left: true }).catch(() => {});
26302
27267
  try {
@@ -26440,6 +27405,9 @@ var Groups = class {
26440
27405
  return;
26441
27406
  }
26442
27407
  if (!session || !group || session.status !== "active" || !rosterHas(session.roster, peerKey)) return;
27408
+ let told = this.told.get(groupId);
27409
+ if (!told) this.told.set(groupId, told = /* @__PURE__ */ new Set());
27410
+ told.add(peerKey);
26443
27411
  const seen = !!group.unseen?.includes(peerKey);
26444
27412
  if (seen) {
26445
27413
  const unseen = group.unseen.filter((key) => key !== peerKey);
@@ -26449,7 +27417,7 @@ var Groups = class {
26449
27417
  const legacy = this.hubs.enabled && this.hubs.edgeReady(groupId, session, group, peerKey, this.host.linkReady(linkId, 4), this.now());
26450
27418
  if (large || legacy || seen) this.store.putGroup(group).catch(() => {});
26451
27419
  if (!this.holdSync(groupId, peerKey)) try {
26452
- this.host.sendOnLink(linkId, session.syncFrame(this.askOf(groupId, session, peerKey)));
27420
+ this.host.sendOnLink(linkId, session.syncFrame(this.askOf(groupId, session, peerKey), peerKey));
26453
27421
  } catch {
26454
27422
  return;
26455
27423
  }
@@ -26585,18 +27553,21 @@ var Groups = class {
26585
27553
  }
26586
27554
  /**
26587
27555
  * The unreachable members whose messages `via` should hand on to me: each is asked of one connected member at a
26588
- * time, so a member whose edges open one after another is not sent the same messages by each of them.
27556
+ * time, so a member whose edges open one after another is not sent the same messages by each of them. Those whose
27557
+ * edge opened in the same moment, not told yet, are asked of `via` too, and their syncs wait for its answer
27558
+ * (`holdSync`): each told at once answered with its own lines alone, and a conversation two of them had while I was
27559
+ * away came as all of one's lines, then all of the other's.
26589
27560
  */
26590
27561
  askOf(groupId, session, via) {
26591
27562
  let asked = this.relayAsked.get(groupId);
26592
27563
  if (!asked) this.relayAsked.set(groupId, asked = /* @__PURE__ */ new Map());
26593
- const edges = this.host.edges(groupId), now = this.now();
27564
+ const edges = this.host.edges(groupId), now = this.now(), told = this.told.get(groupId);
26594
27565
  const up = (key) => {
26595
27566
  const edge = edges.get(key);
26596
27567
  return !!edge && this.host.linkReady(edge);
26597
27568
  };
26598
27569
  const out = [];
26599
- for (const key of this.unreachable(groupId, session)) {
27570
+ for (const key of session.others.filter((key) => !up(key) || !told?.has(key))) {
26600
27571
  if (key === via) continue;
26601
27572
  const prior = asked.get(key);
26602
27573
  if (prior && up(prior.via) && now - prior.at < 6e4) continue;
@@ -26677,7 +27648,7 @@ var Groups = class {
26677
27648
  });
26678
27649
  }
26679
27650
  try {
26680
- this.host.sendOnLink(edges.get(next), session.syncFrame([...ask]));
27651
+ this.host.sendOnLink(edges.get(next), session.syncFrame([...ask], next));
26681
27652
  } catch {}
26682
27653
  }
26683
27654
  /**
@@ -26698,9 +27669,9 @@ var Groups = class {
26698
27669
  for (const key of tried.keys()) if (!lacking.has(key)) tried.delete(key);
26699
27670
  const out = [];
26700
27671
  for (const [key, mark] of lacking) {
26701
- if (key === via || !up(key)) continue;
27672
+ if (key === via || !up(key) || !session.mayHold(via, key)) continue;
26702
27673
  const prior = asked.get(key);
26703
- if (prior && prior.via !== via && up(prior.via) && now - prior.at < 6e4) continue;
27674
+ if (prior && prior.via !== via && up(prior.via) && now - prior.at < 6e4 && session.mayHold(prior.via, key)) continue;
26704
27675
  let entry = tried.get(key);
26705
27676
  if (!entry || entry.mark !== mark) tried.set(key, entry = {
26706
27677
  mark,
@@ -26746,6 +27717,11 @@ var Groups = class {
26746
27717
  entry.at = now;
26747
27718
  return true;
26748
27719
  }
27720
+ /** Whether some of a member's messages are still on their way (not known gone): in any epoch, or in those below `below`. */
27721
+ onTheirWay(groupId, sender, below = Infinity) {
27722
+ const gone = this.gapsGone.get(groupId)?.get(sender);
27723
+ return Object.entries(this.sessions.get(groupId)?.missOf(sender) ?? {}).some(([e, gaps]) => Number(e) < below && gaps.some((n) => n > (gone?.[e] ?? -1)));
27724
+ }
26749
27725
  /**
26750
27726
  * Messages nobody holds any more: every connected member, their author included, was asked for the same missing
26751
27727
  * numbers `MESH_GAP_AGAIN_MS` ago or more and none arrived. An author keeps its last 32 and the others the group's
@@ -26759,7 +27735,7 @@ var Groups = class {
26759
27735
  for (const [key, mark] of lacking) {
26760
27736
  const entry = tried.get(key);
26761
27737
  if (!entry || entry.mark !== mark || entry.at === void 0 || now - entry.at < 1e4) continue;
26762
- if (!connected.includes(key) || !connected.every((member) => entry.of.has(member))) continue;
27738
+ if (!connected.includes(key) || !connected.every((member) => entry.of.has(member) || !session.mayHold(member, key))) continue;
26763
27739
  let gone = this.gapsGone.get(groupId);
26764
27740
  if (!gone) this.gapsGone.set(groupId, gone = /* @__PURE__ */ new Map());
26765
27741
  const upTo = { ...gone.get(key) };
@@ -26809,7 +27785,9 @@ var Groups = class {
26809
27785
  continue;
26810
27786
  }
26811
27787
  if (now - last.at < 1e4 || !up(last.via)) continue;
26812
- if (session.filledSince(key, last.asked)) again.set(last.via, [...again.get(last.via) ?? [], key]);
27788
+ if (!session.filledSince(key, last.asked)) continue;
27789
+ if (session.mayHold(last.via, key)) again.set(last.via, [...again.get(last.via) ?? [], key]);
27790
+ else fresh.set(key, mark);
26813
27791
  }
26814
27792
  const asked = this.relayAsked.get(groupId);
26815
27793
  for (const [via, keys] of again) {
@@ -26830,18 +27808,19 @@ var Groups = class {
26830
27808
  });
26831
27809
  }
26832
27810
  try {
26833
- this.host.sendOnLink(edges.get(via), session.syncFrame(keys));
27811
+ this.host.sendOnLink(edges.get(via), session.syncFrame(keys, via));
26834
27812
  } catch {}
26835
27813
  }
26836
27814
  if (!fresh.size || !connected.length) return;
26837
27815
  const at = () => connected[(gossipStart(session.myKey) + this.gossipTurn++) % connected.length];
27816
+ const helps = (member) => !fresh.has(member) && [...fresh.keys()].some((key) => session.mayHold(member, key));
26838
27817
  let via = at();
26839
- for (let i = 1; i < connected.length && fresh.has(via); i++) via = at();
27818
+ for (let i = 1; i < connected.length && !helps(via); i++) via = at();
26840
27819
  const keys = this.gapsOf(groupId, session, via, fresh);
26841
27820
  if (!keys.length) return;
26842
27821
  this.lastGossip.set(groupId, now);
26843
27822
  try {
26844
- this.host.sendOnLink(edges.get(via), session.syncFrame(keys));
27823
+ this.host.sendOnLink(edges.get(via), session.syncFrame(keys, via));
26845
27824
  } catch {}
26846
27825
  }
26847
27826
  /**
@@ -26863,9 +27842,12 @@ var Groups = class {
26863
27842
  const edges = this.host.edges(groupId);
26864
27843
  if (!this.hubs.large(session) && session.others.some((key) => !edges.has(key))) this.reconcileEdges(groupId);
26865
27844
  }
26866
- const away = this.unreachable(groupId, session);
26867
- this.noteDown(groupId, session, away, now);
26868
- const connected = session.others.filter((key) => !away.includes(key));
27845
+ const down = this.unreachable(groupId, session);
27846
+ this.noteDown(groupId, session, down, now);
27847
+ const told = this.told.get(groupId);
27848
+ for (const key of down) told?.delete(key);
27849
+ const connected = session.others.filter((key) => !down.includes(key) && told?.has(key));
27850
+ const away = session.others.filter((key) => !connected.includes(key));
26869
27851
  this.gapsAgain(groupId, session, now, connected);
26870
27852
  if (now - (this.lastGossip.get(groupId) ?? -Infinity) < 6e4) continue;
26871
27853
  const edges = this.host.edges(groupId);
@@ -26883,7 +27865,7 @@ var Groups = class {
26883
27865
  const ask = [...away, ...this.gapsOf(groupId, session, via, lacking)];
26884
27866
  if (!this.ownGap(groupId, via, lacking) && !ask.length) continue;
26885
27867
  try {
26886
- this.host.sendOnLink(edges.get(via), session.syncFrame(ask));
27868
+ this.host.sendOnLink(edges.get(via), session.syncFrame(ask, via));
26887
27869
  } catch {}
26888
27870
  }
26889
27871
  }
@@ -26987,21 +27969,27 @@ var Groups = class {
26987
27969
  ...m.frame && { frame: m.frame }
26988
27970
  };
26989
27971
  const completed = !!m.completes && !!this.host.completeMessage;
27972
+ const row = {
27973
+ sender: m.sender,
27974
+ epoch: m.epoch,
27975
+ seq: m.seq,
27976
+ id: m.id
27977
+ };
27978
+ const above = completed || m.sender === session.myKey ? void 0 : this.authorOrder.above(state.id, row);
27979
+ const place = above === void 0 ? void 0 : await this.host.placeAbove?.(message.linkId, above).catch(() => void 0);
27980
+ const placedAbove = place === void 0 ? void 0 : above;
26990
27981
  const placing = completed ? void 0 : this.lineOrder.placing(MESSAGE_LINK(state.id), {
26991
27982
  id: m.id,
26992
27983
  epoch: m.epoch,
26993
27984
  line: false
26994
- }, this.now());
26995
- const stored = completed ? (await this.host.completeMessage(message), false) : await this.host.storeMessage(message);
27985
+ }, this.now(), placedAbove);
27986
+ const stored = completed ? (await this.host.completeMessage(message), false) : await this.host.storeMessage(message, m.live, place);
26996
27987
  placing?.(stored !== false);
26997
27988
  if (m.frame && (completed || stored !== false)) await this.frameKept(state.id, m.id, m.frame);
26998
27989
  if (!completed && stored !== false) this.authorOrder.placed(state.id, {
26999
- sender: m.sender,
27000
- epoch: m.epoch,
27001
- seq: m.seq,
27002
- id: m.id,
27990
+ ...row,
27003
27991
  at: this.now()
27004
- });
27992
+ }, placedAbove);
27005
27993
  const came = m.sender === session.myKey ? timestamp : cameAt(timestamp, stored, arrivalNow(this.now()));
27006
27994
  this.lastMessageAt.set(state.id, Math.max(this.lastMessageAt.get(state.id) ?? 0, came));
27007
27995
  if (mentioned) this.lastMentionAt.set(state.id, Math.max(this.lastMentionAt.get(state.id) ?? 0, came));
@@ -30224,7 +31212,10 @@ var SWAP_POLL_MS = 3e4;
30224
31212
  var SWAP_SLOW_POLL_MS = 6e5;
30225
31213
  /** How long a swap whose inputs were given back is still asked about, in case the mint made it after all. */
30226
31214
  var SWAP_WATCH_MS = 36e5;
30227
- function reviewedCashuFee(preview) {
31215
+ /** Cashu's library (174 KiB) loads with the wallet's first use of a mint or a token, not with the app's start. */
31216
+ var loadCashu = () => import("@cashu/cashu-ts");
31217
+ async function reviewedCashuFee(preview) {
31218
+ const { Amount } = await loadCashu();
30228
31219
  const fee = preview.inputs.reduce((sum, proof) => sum + Amount.from(proof.amount).toNumber(), 0) - (preview.keepOutputs ?? []).reduce((sum, output) => sum + Amount.from(output.blindedMessage.amount).toNumber(), 0) - preview.amount.toNumber();
30229
31220
  if (!Number.isSafeInteger(fee) || fee < 0) throw new Error("Invalid Cashu prepared fee");
30230
31221
  return fee;
@@ -30280,9 +31271,9 @@ var toStored = (mint, p, reserved) => ({
30280
31271
  });
30281
31272
  /** The note of the ecash "Get test coins" brought (the engine plays its own sound for it). */
30282
31273
  var TEST_COINS_NOTE = "Test coins from the test mint";
30283
- var swapId = () => `swap-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
31274
+ var swapId = () => `swap-${Date.now()}-${crypto.randomUUID().slice(0, 8)}`;
30284
31275
  var walletTx = (mint, kind, amount, fee, note) => ({
30285
- id: `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`,
31276
+ id: `${Date.now()}-${crypto.randomUUID().slice(0, 8)}`,
30286
31277
  timestamp: Date.now(),
30287
31278
  mint,
30288
31279
  kind,
@@ -30591,6 +31582,7 @@ var CashuWallet = class CashuWallet {
30591
31582
  const quote = await wrap((await store(STORES$1.quotes, "readonly")).get(id));
30592
31583
  if (!quote || quote.issuedUnclaimed || quote.held) return null;
30593
31584
  const wallet = await this.wallet(quote.mint);
31585
+ const { OutputData, isMintOperationError } = await loadCashu();
30594
31586
  const answer = await wallet.checkMintQuoteBolt11(quote.quote);
30595
31587
  const { state } = answer;
30596
31588
  if (state === "UNPAID") {
@@ -30634,7 +31626,10 @@ var CashuWallet = class CashuWallet {
30634
31626
  /** The ecash of a paid invoice goes in with its history line, in the transaction that drops the quote. */
30635
31627
  async storeMinted(quote, proofs) {
30636
31628
  const minted = sats(proofs);
30637
- const tx = walletTx(quote.mint, "lightning-in", minted, quote.amount - minted, quote.paymentId ? "Request paid over Lightning" : quote.testCoins ? TEST_COINS_NOTE : void 0);
31629
+ const tx = {
31630
+ ...walletTx(quote.mint, "lightning-in", minted, quote.amount - minted, quote.paymentId ? "Request paid over Lightning" : quote.testCoins ? TEST_COINS_NOTE : void 0),
31631
+ quote: quote.quote
31632
+ };
30638
31633
  await transact([
30639
31634
  STORES$1.proofs,
30640
31635
  STORES$1.walletTx,
@@ -30670,7 +31665,7 @@ var CashuWallet = class CashuWallet {
30670
31665
  if (await this.balanceAt(mint) < amount) continue;
30671
31666
  return this.locked(mint, async () => {
30672
31667
  const { inputs, keep, send, swap } = await this.split(mint, amount, await this.proofsAt(mint));
30673
- const token = getEncodedToken({
31668
+ const token = (await loadCashu()).getEncodedToken({
30674
31669
  mint,
30675
31670
  proofs: send,
30676
31671
  unit: UNIT$1
@@ -30704,10 +31699,10 @@ var CashuWallet = class CashuWallet {
30704
31699
  return this.locked(mint, async () => {
30705
31700
  const preview = await (await this.wallet(mint)).ops.send(amount, asProofLike(await this.proofsAt(mint))).includeFees(true).prepare();
30706
31701
  return {
30707
- fee: reviewedCashuFee(preview),
31702
+ fee: await reviewedCashuFee(preview),
30708
31703
  prepared: {
30709
31704
  mint,
30710
- swap: serializeSwapPreview(preview)
31705
+ swap: (await loadCashu()).serializeSwapPreview(preview)
30711
31706
  }
30712
31707
  };
30713
31708
  });
@@ -30715,8 +31710,8 @@ var CashuWallet = class CashuWallet {
30715
31710
  /** Reserve before the mint call. A timeout keeps these inputs reserved for read-only recovery. */
30716
31711
  async executeReviewedCashu(review, prepared) {
30717
31712
  return this.locked(prepared.mint, async () => {
30718
- const preview = deserializeSwapPreview(prepared.swap);
30719
- if (prepared.mint !== review.provider || preview.amount.toNumber() !== review.amount || reviewedCashuFee(preview) !== review.fee) throw new PaymentPreflightError("Cashu preview does not match review");
31713
+ const preview = (await loadCashu()).deserializeSwapPreview(prepared.swap);
31714
+ if (prepared.mint !== review.provider || preview.amount.toNumber() !== review.amount || await reviewedCashuFee(preview) !== review.fee) throw new PaymentPreflightError("Cashu preview does not match review");
30720
31715
  const tx = (await openDb()).transaction(STORES$1.proofs, "readwrite");
30721
31716
  await new Promise((resolve, reject) => {
30722
31717
  const proofs = tx.objectStore(STORES$1.proofs);
@@ -30746,6 +31741,7 @@ var CashuWallet = class CashuWallet {
30746
31741
  const stored = await wrap((await store(STORES$1.payments, "readonly")).get(review.id));
30747
31742
  if (stored?.token) return stored.token;
30748
31743
  const wallet = await this.wallet(prepared.mint);
31744
+ const { OutputData } = await loadCashu();
30749
31745
  const keep = (prepared.swap.keepOutputs ?? []).map(OutputData.deserialize);
30750
31746
  const send = (prepared.swap.sendOutputs ?? []).map(OutputData.deserialize);
30751
31747
  const outputs = [...keep, ...send];
@@ -30775,6 +31771,7 @@ var CashuWallet = class CashuWallet {
30775
31771
  if (recovered.outputs.length === 0 && recovered.signatures.length === 0) return "none";
30776
31772
  if (recovered.outputs.length !== outputs.length || recovered.signatures.length !== outputs.length) return "some";
30777
31773
  const keys = (await wallet.mint.getKeys()).keysets;
31774
+ const { Amount } = await loadCashu();
30778
31775
  const restored = [];
30779
31776
  for (const output of outputs) {
30780
31777
  const index = recovered.outputs.findIndex((o) => o.B_ === output.blindedMessage.B_);
@@ -30786,6 +31783,7 @@ var CashuWallet = class CashuWallet {
30786
31783
  return restored;
30787
31784
  }
30788
31785
  async finishReviewedCashu(review, prepared, keep, send) {
31786
+ const { getEncodedToken, deserializeSwapPreview } = await loadCashu();
30789
31787
  const token = getEncodedToken({
30790
31788
  mint: prepared.mint,
30791
31789
  proofs: send,
@@ -30828,6 +31826,7 @@ var CashuWallet = class CashuWallet {
30828
31826
  * not SPENT), and the mint signed none of its outputs. Read-only: it asks, and never sends the swap.
30829
31827
  */
30830
31828
  async reviewedCashuNeverSwapped(prepared) {
31829
+ const { OutputData, deserializeSwapPreview } = await loadCashu();
30831
31830
  const preview = deserializeSwapPreview(prepared.swap);
30832
31831
  if (!preview.inputs.length) return false;
30833
31832
  const wallet = await this.wallet(prepared.mint);
@@ -30846,6 +31845,7 @@ var CashuWallet = class CashuWallet {
30846
31845
  * pending. Read-only. A mint that does not restore cannot say, and the answer is no.
30847
31846
  */
30848
31847
  async reviewedCashuSpentElsewhere(prepared) {
31848
+ const { OutputData, deserializeSwapPreview } = await loadCashu();
30849
31849
  const preview = deserializeSwapPreview(prepared.swap);
30850
31850
  if (!preview.inputs.length) return false;
30851
31851
  const wallet = await this.wallet(prepared.mint);
@@ -30863,7 +31863,7 @@ var CashuWallet = class CashuWallet {
30863
31863
  * are free again, and the rest of the wallet's ecash at that mint is checked the same way. Idempotent.
30864
31864
  */
30865
31865
  async dropReviewedCashu(prepared) {
30866
- const preview = deserializeSwapPreview(prepared.swap);
31866
+ const preview = (await loadCashu()).deserializeSwapPreview(prepared.swap);
30867
31867
  await this.locked(prepared.mint, async () => {
30868
31868
  const states = await (await this.wallet(prepared.mint)).checkProofsStates(preview.inputs.map((p) => ({
30869
31869
  secret: p.secret,
@@ -30908,7 +31908,7 @@ var CashuWallet = class CashuWallet {
30908
31908
  }
30909
31909
  /** Gives back the inputs a reviewed swap reserved, once the mint proved it never happened. Idempotent. */
30910
31910
  async releaseReviewedCashu(prepared) {
30911
- const preview = deserializeSwapPreview(prepared.swap);
31911
+ const preview = (await loadCashu()).deserializeSwapPreview(prepared.swap);
30912
31912
  await this.locked(prepared.mint, async () => {
30913
31913
  await transact([STORES$1.proofs], (stores) => {
30914
31914
  const proofs = stores[STORES$1.proofs];
@@ -30933,7 +31933,7 @@ var CashuWallet = class CashuWallet {
30933
31933
  return (await wrap((await store(STORES$1.payments, "readonly")).get(id)))?.state === "reclaimed";
30934
31934
  }
30935
31935
  async reviewedCashuSpent(prepared) {
30936
- const outputs = (prepared.swap.sendOutputs ?? []).map(OutputData.deserialize);
31936
+ const outputs = (prepared.swap.sendOutputs ?? []).map((await loadCashu()).OutputData.deserialize);
30937
31937
  const states = await (await this.wallet(prepared.mint)).checkProofsStates(outputs.map((o) => ({
30938
31938
  secret: new TextDecoder().decode(o.secret),
30939
31939
  id: o.blindedMessage.id
@@ -30941,8 +31941,10 @@ var CashuWallet = class CashuWallet {
30941
31941
  return states.length > 0 && states.length === outputs.length && states.every((s) => s.state === "SPENT");
30942
31942
  }
30943
31943
  /** Reads a token or a payment request without contacting a mint. Nothing here moves money. */
30944
- inspect(text) {
31944
+ async inspect(text) {
30945
31945
  const value = text.trim();
31946
+ if (!/^(cashu|creq)[AB]/i.test(value)) return null;
31947
+ const { decodePaymentRequest, getTokenMetadata } = await loadCashu();
30946
31948
  try {
30947
31949
  if (/^cashu[AB]/i.test(value)) {
30948
31950
  const metadata = getTokenMetadata(value);
@@ -30977,6 +31979,7 @@ var CashuWallet = class CashuWallet {
30977
31979
  let mint;
30978
31980
  let faceValue;
30979
31981
  let unit;
31982
+ const { getTokenMetadata, serializeSwapPreview } = await loadCashu();
30980
31983
  try {
30981
31984
  const metadata = getTokenMetadata(token);
30982
31985
  unit = metadata.unit;
@@ -31047,6 +32050,7 @@ var CashuWallet = class CashuWallet {
31047
32050
  */
31048
32051
  async split(mint, amount, proofs) {
31049
32052
  const wallet = await this.wallet(mint);
32053
+ const { getEncodedToken, serializeSwapPreview } = await loadCashu();
31050
32054
  const among = (chosen) => {
31051
32055
  const secrets = new Set(chosen.map((p) => p.secret));
31052
32056
  return proofs.filter((p) => secrets.has(p.secret));
@@ -31098,6 +32102,7 @@ var CashuWallet = class CashuWallet {
31098
32102
  * `pollSwaps` settles it. Runs under the mint's lock.
31099
32103
  */
31100
32104
  async completeSwap(swap) {
32105
+ const { deserializeSwapPreview, isMintOperationError } = await loadCashu();
31101
32106
  try {
31102
32107
  return await (await this.wallet(swap.mint)).completeSwap(deserializeSwapPreview(swapOf(swap)));
31103
32108
  } catch (error) {
@@ -31125,6 +32130,7 @@ var CashuWallet = class CashuWallet {
31125
32130
  */
31126
32131
  async swapOutcome(swap, refused = false) {
31127
32132
  const wallet = await this.wallet(swap.mint);
32133
+ const { OutputData } = await loadCashu();
31128
32134
  const saved = swapOf(swap);
31129
32135
  const keep = (saved.keepOutputs ?? []).map(OutputData.deserialize);
31130
32136
  const send = (saved.sendOutputs ?? []).map(OutputData.deserialize);
@@ -31283,6 +32289,15 @@ var CashuWallet = class CashuWallet {
31283
32289
  settleSwaps(mints) {
31284
32290
  return this.pollSwaps(mints);
31285
32291
  }
32292
+ /**
32293
+ * A contact's ecash for this request of ours is being redeemed: written down, and the mint has not said how it ended,
32294
+ * or it has and its chat was not told yet. Released: the mint never made it in time, and the ecash went back. Stuck
32295
+ * past its watch hour (spent, and a mint that cannot say by what): kept to be asked about, but no longer expected to
32296
+ * pay the request, which another member may pay.
32297
+ */
32298
+ async redeemingFor(requestId) {
32299
+ return (await wrap((await store(STORES$1.swaps, "readonly")).getAll())).some((s) => s.kind === "receive" && !s.released && s.payment?.requestId === requestId && !(s.stuck && Date.now() >= s.createdAt + SWAP_WATCH_MS));
32300
+ }
31286
32301
  async pollSwaps(mints) {
31287
32302
  if (!mints) {
31288
32303
  if (this.swapTimer) clearTimeout(this.swapTimer);
@@ -31330,34 +32345,36 @@ var CashuWallet = class CashuWallet {
31330
32345
  }));
31331
32346
  if (stuck === 1) this.events.onChange();
31332
32347
  later(wait);
31333
- } else if (outcome.idle && swap.kind === "receive" && !swap.released) try {
31334
- const { keep } = await (await this.wallet(mint)).completeSwap(deserializeSwapPreview(swapOf(swap)));
31335
- if (await this.finishSwap(swap, keep, [], true)) {
31336
- const told = await this.tellSwap(swap, true);
31337
- if (swap.payment && told) await wrap((await store(STORES$1.swaps, "readwrite")).delete(swap.id));
31338
- else if (swap.payment) later(SWAP_POLL_MS);
31339
- this.events.onChange();
31340
- }
31341
- } catch (error) {
31342
- swap = {
31343
- ...swap,
31344
- attemptEndedAt: Date.now()
31345
- };
31346
- await wrap((await store(STORES$1.swaps, "readwrite")).put(swap));
31347
- const refusal = isMintOperationError(error) ? await this.swapOutcome(swap, true).catch(() => void 0) : void 0;
31348
- if (refusal?.state === "done") {
31349
- if (await this.finishSwap(swap, refusal.keep, refusal.send, true)) {
32348
+ } else if (outcome.idle && swap.kind === "receive" && !swap.released) {
32349
+ const { deserializeSwapPreview, isMintOperationError } = await loadCashu();
32350
+ try {
32351
+ const { keep } = await (await this.wallet(mint)).completeSwap(deserializeSwapPreview(swapOf(swap)));
32352
+ if (await this.finishSwap(swap, keep, [], true)) {
31350
32353
  const told = await this.tellSwap(swap, true);
31351
32354
  if (swap.payment && told) await wrap((await store(STORES$1.swaps, "readwrite")).delete(swap.id));
32355
+ else if (swap.payment) later(SWAP_POLL_MS);
32356
+ this.events.onChange();
31352
32357
  }
31353
- this.events.onChange();
31354
- } else if (refusal?.state === "never") {
31355
- await this.dropSwap(swap, refusal.gone);
31356
- await this.tellSwap(swap, false);
31357
- this.events.onChange();
31358
- } else later(SWAP_POLL_MS);
31359
- }
31360
- else if (outcome.pending && swap.kind === "receive" && !swap.released && Date.now() >= swapOverdueAt(swap)) {
32358
+ } catch (error) {
32359
+ swap = {
32360
+ ...swap,
32361
+ attemptEndedAt: Date.now()
32362
+ };
32363
+ await wrap((await store(STORES$1.swaps, "readwrite")).put(swap));
32364
+ const refusal = isMintOperationError(error) ? await this.swapOutcome(swap, true).catch(() => void 0) : void 0;
32365
+ if (refusal?.state === "done") {
32366
+ if (await this.finishSwap(swap, refusal.keep, refusal.send, true)) {
32367
+ const told = await this.tellSwap(swap, true);
32368
+ if (swap.payment && told) await wrap((await store(STORES$1.swaps, "readwrite")).delete(swap.id));
32369
+ }
32370
+ this.events.onChange();
32371
+ } else if (refusal?.state === "never") {
32372
+ await this.dropSwap(swap, refusal.gone);
32373
+ await this.tellSwap(swap, false);
32374
+ this.events.onChange();
32375
+ } else later(SWAP_POLL_MS);
32376
+ }
32377
+ } else if (outcome.pending && swap.kind === "receive" && !swap.released && Date.now() >= swapOverdueAt(swap)) {
31361
32378
  if (Date.now() < swap.createdAt + SWAP_WATCH_MS) {
31362
32379
  await wrap((await store(STORES$1.swaps, "readwrite")).put({
31363
32380
  ...swap,
@@ -31451,6 +32468,7 @@ var CashuWallet = class CashuWallet {
31451
32468
  }
31452
32469
  async melt(quoteId, mint, note, paymentId) {
31453
32470
  const wallet = await this.wallet(mint);
32471
+ const { MeltChangeError, OutputData, isMintOperationError } = await loadCashu();
31454
32472
  const quote = await wallet.checkMeltQuoteBolt11(quoteId);
31455
32473
  if ((await wrap((await store(STORES$1.melts, "readonly")).getAll())).some((m) => m.quote === quote.quote || m.request === quote.request)) throw new Error("This invoice is already being paid");
31456
32474
  const shown = this.shownQuotes.get(quoteId);
@@ -31544,6 +32562,7 @@ var CashuWallet = class CashuWallet {
31544
32562
  */
31545
32563
  async settleMelt(melt) {
31546
32564
  const wallet = await this.wallet(melt.mint);
32565
+ const { Amount, OutputData } = await loadCashu();
31547
32566
  const quote = await wallet.checkMeltQuoteBolt11(melt.quote);
31548
32567
  if (quote.state === "PAID") {
31549
32568
  const signatures = (quote.change ?? []).filter((sig) => !Amount.from(sig.amount).isZero());
@@ -31649,7 +32668,7 @@ var CashuWallet = class CashuWallet {
31649
32668
  out.push({
31650
32669
  mint,
31651
32670
  amount: total(proofs),
31652
- token: getEncodedToken({
32671
+ token: (await loadCashu()).getEncodedToken({
31653
32672
  mint,
31654
32673
  proofs,
31655
32674
  unit: UNIT$1
@@ -31662,7 +32681,7 @@ var CashuWallet = class CashuWallet {
31662
32681
  let wallet = this.wallets.get(mint);
31663
32682
  if (!wallet) {
31664
32683
  wallet = (async () => {
31665
- const instance = new Wallet(mint, { unit: UNIT$1 });
32684
+ const instance = new (await (loadCashu())).Wallet(mint, { unit: UNIT$1 });
31666
32685
  await within(MINT_TIMEOUT_MS, instance.loadMint(), mint);
31667
32686
  return instance;
31668
32687
  })();
@@ -32302,12 +33321,14 @@ var PaymentDesk = class {
32302
33321
  return request.linkId === linkId || !!request.group && this.host.groupOf?.(linkId) === request.group;
32303
33322
  }
32304
33323
  /**
32305
- * Why a token cannot pay a request to the group, checked before anything is redeemed: already paid, or it would
32306
- * not settle it (too little, a mint the request did not name). Undefined when it can.
33324
+ * Why a token cannot pay a request to the group, checked before anything is redeemed: already paid, another member's
33325
+ * ecash for it still being redeemed (the mint has not answered), or it would not settle it (too little, a mint the
33326
+ * request did not name). Undefined when it can.
32307
33327
  */
32308
- refuseForGroup(request, payment) {
33328
+ async refuseForGroup(request, payment) {
32309
33329
  if (request.state !== "pending") return "Already paid by another member of the group";
32310
- const token = this.wallet.inspect?.(payment.endpoint[1]);
33330
+ if (await this.wallet.redeemingFor?.(request.id)) return "Another member of the group is paying it";
33331
+ const token = await this.wallet.inspect?.(payment.endpoint[1]);
32311
33332
  if (token && token.kind === "token" && (token.amount < request.amount || !(request.mints ?? []).includes(token.mint))) return "This ecash does not pay the request: the amount or the mint differs";
32312
33333
  }
32313
33334
  /**
@@ -32399,6 +33420,24 @@ var PaymentDesk = class {
32399
33420
  }).catch(() => {});
32400
33421
  }
32401
33422
  /**
33423
+ * A Testnet invoice found in a chat message, paid from its card: the chat's contact is told as `sayPaid` does, with
33424
+ * no request (it may be one from their Receive screen, held until a payer says it paid).
33425
+ */
33426
+ sayInvoicePaid(linkId, invoice) {
33427
+ const link = this.host.getLink(linkId);
33428
+ const amount = decodeBolt11(invoice)?.amountSat;
33429
+ if (!link || !amount) return;
33430
+ this.sendTo(link, {
33431
+ id: newId(),
33432
+ timestamp: Date.now(),
33433
+ amount: {
33434
+ value: String(amount),
33435
+ asset: UNIT
33436
+ },
33437
+ endpoint: [ENDPOINT.bolt11, invoice.trim()]
33438
+ }).catch(() => {});
33439
+ }
33440
+ /**
32402
33441
  * Takes back ecash the peer never redeemed. If they did redeem it, the mint says so and the payment is settled.
32403
33442
  * Resolves to what came back of Cashu ecash (and the mint's fee for it); undefined when nothing did, or not Cashu.
32404
33443
  */
@@ -32735,7 +33774,7 @@ var PaymentDesk = class {
32735
33774
  });
32736
33775
  return;
32737
33776
  }
32738
- const inspected = this.wallet.inspect?.(payment.endpoint[1]);
33777
+ const inspected = await this.wallet.inspect?.(payment.endpoint[1]);
32739
33778
  if (payment.endpoint[0] === ENDPOINT.cashu && inspected !== void 0 && inspected?.kind !== "token") {
32740
33779
  link.sendPaymentResult({
32741
33780
  id: payment.id,
@@ -32754,7 +33793,7 @@ var PaymentDesk = class {
32754
33793
  }
32755
33794
  const forGroup = payment.requestId ? this.payments.get(payment.requestId) : void 0;
32756
33795
  if (forGroup?.group && forGroup.kind === "request" && forGroup.direction === "out" && this.owns(forGroup, linkId)) {
32757
- const refused = this.refuseForGroup(forGroup, payment);
33796
+ const refused = await this.refuseForGroup(forGroup, payment);
32758
33797
  if (refused) {
32759
33798
  link.sendPaymentResult({
32760
33799
  id: payment.id,
@@ -33022,6 +34061,7 @@ var PaymentDesk = class {
33022
34061
  */
33023
34062
  async receiveCheck(linkId, payment) {
33024
34063
  const link = this.host.getLink(linkId);
34064
+ if (link && !payment.requestId && payment.endpoint[0] === ENDPOINT.bolt11) return this.receiveInvoicePaid(linkId, link, payment.endpoint[1]);
33025
34065
  const request = payment.requestId ? this.payments.get(payment.requestId) : void 0;
33026
34066
  if (!link || !request || request.kind !== "request" || request.direction !== "out" || !this.owns(request, linkId)) return;
33027
34067
  const rail = request.target ? request.target.method : request.invoice ? "lightning" : void 0;
@@ -33044,6 +34084,22 @@ var PaymentDesk = class {
33044
34084
  else await this.reconcileArkReceipts();
33045
34085
  } catch {}
33046
34086
  }
34087
+ /**
34088
+ * The contact says it paid an invoice of ours it found in the chat, with no request: a Receive-screen invoice is
34089
+ * held on a test mint until then (StoredQuote `held`), and that one alone is let go. A request's invoice is spoken
34090
+ * for by its own contact only (`receiveCheck`). Bounded: one look every 3 seconds per chat.
34091
+ */
34092
+ async receiveInvoicePaid(linkId, link, invoice) {
34093
+ if (!link.allowsPayment("lightning")) return;
34094
+ const now = Date.now();
34095
+ if (now - (this.lastCheckFrom.get(`chat:${linkId}`) ?? 0) < 3e3) return;
34096
+ this.lastCheckFrom.set(`chat:${linkId}`, now);
34097
+ const wanted = invoice.trim().toLowerCase();
34098
+ try {
34099
+ const quote = (await this.wallet.quotes()).find((q) => q.held && !q.paymentId && q.invoice.toLowerCase() === wanted);
34100
+ if (quote) await this.wallet.vouch(quote.invoice);
34101
+ } catch {}
34102
+ }
33047
34103
  /** A Lightning payment of a contact's request that was left pending (or unknown) settled. */
33048
34104
  async onLightningResolved(op, paid) {
33049
34105
  const found = op.paymentId ? this.payments.get(op.paymentId) : void 0;
@@ -34488,7 +35544,7 @@ function writtenAt(message, leads) {
34488
35544
  }
34489
35545
  var JOIN_NOTICE = /^👋 (?:.+ )?joined$/;
34490
35546
  /** What a newly stored row says, or null when it is quiet. `type`: "message" for the contact's, "sent" for mine. */
34491
- function messageAttention(type, message, { startedAt, newest, now }) {
35547
+ function messageAttention(type, message, { startedAt, newest, now, lateAt }) {
34492
35548
  if (message.timestamp < startedAt || message.file || message.paymentId || JOIN_NOTICE.test(message.text)) return null;
34493
35549
  if (message.event) {
34494
35550
  const cue = groupCue(message);
@@ -34504,6 +35560,7 @@ function messageAttention(type, message, { startedAt, newest, now }) {
34504
35560
  };
34505
35561
  if (!message.text.trim()) return null;
34506
35562
  if (newest !== void 0 && message.timestamp < newest - 6e4 && now - message.timestamp > 6e4) return null;
35563
+ if (lateAt !== void 0 && now - lateAt < 5e3 && now - message.timestamp > 6e4) return null;
34507
35564
  return {
34508
35565
  type,
34509
35566
  mention: !!message.mentioned
@@ -34942,6 +35999,11 @@ var GhostlyNode = class GhostlyNode {
34942
35999
  transfersRestored = false;
34943
36000
  /** `start` has loaded the groups (EngineState.groupsLoaded). */
34944
36001
  groupsLoaded = false;
36002
+ /** Settles as `start` has loaded the groups: a group's history reads as stored from then on (`EngineServer.handle`). */
36003
+ groupsLoadedNow;
36004
+ groupsRead = new Promise((resolve) => {
36005
+ this.groupsLoadedNow = resolve;
36006
+ });
34945
36007
  feedbackStartedAt = Date.now();
34946
36008
  feedbackIds = /* @__PURE__ */ new Set();
34947
36009
  walletFeedbackReady = false;
@@ -34966,28 +36028,43 @@ var GhostlyNode = class GhostlyNode {
34966
36028
  }
34967
36029
  /** The newest time written in each chat while this engine runs: a message written well before it is a catch-up. */
34968
36030
  newestAt = /* @__PURE__ */ new Map();
36031
+ /** Per group: when the last message written well before it came, came. What comes after it, as old, is its catch-up. */
36032
+ lateAt = /* @__PURE__ */ new Map();
34969
36033
  /** The last place given in each chat while this engine runs (`arrivalKey`). */
34970
36034
  placedAt = /* @__PURE__ */ new Map();
34971
36035
  /**
34972
- * Each 1:1 chat's contact: how far the time its latest live messages said was from when they came (`CLOCK_SAMPLES`
34973
- * of them). What tells a contact's clock that is behind from a message that is late (`writtenAt`).
36036
+ * Each 1:1 chat's contact, and each group's member (`clockOf`): how far the time its latest live messages said was
36037
+ * from when they came (`CLOCK_SAMPLES` of them). What tells a clock that is behind from a message that is late (`writtenAt`).
34974
36038
  */
34975
36039
  clockLeads = /* @__PURE__ */ new Map();
34976
- /** A contact's row came on the live session of a 1:1 chat: what it says of the contact's clock is kept. */
34977
- clockSample(message) {
34978
- if (message.sender !== "peer" || message.sentAt === void 0 || message.event || message.via !== "datalink" || message.linkId.startsWith("group:")) return;
34979
- this.clockLeads.set(message.linkId, [...this.clockLeads.get(message.linkId) ?? [], message.sentAt - message.timestamp].slice(-5));
36040
+ /** Whose clock a row's time is: a 1:1 chat's contact, or the group's member who wrote it. */
36041
+ clockOf(message) {
36042
+ return message.linkId.startsWith("group:") ? `${message.linkId} ${message.member ?? ""}` : message.linkId;
36043
+ }
36044
+ /**
36045
+ * A contact's row came on the live session of a 1:1 chat, or a member's straight from its author (`live`, never one
36046
+ * handed on or caught up): what it says of that clock is kept.
36047
+ */
36048
+ clockSample(message, live = false) {
36049
+ if (message.sender !== "peer" || message.sentAt === void 0 || message.event || message.via !== "datalink") return;
36050
+ if (message.linkId.startsWith("group:") && !(live && message.member)) return;
36051
+ const clock = this.clockOf(message);
36052
+ this.clockLeads.set(clock, [...this.clockLeads.get(clock) ?? [], message.sentAt - message.timestamp].slice(-5));
34980
36053
  }
34981
36054
  /** Only a new message, or mine going out, says anything (engine/attention.ts): judged by when it was written. */
34982
36055
  messageFeedback(type, message, newest = this.newestAt.get(message.linkId)) {
36056
+ const written = writtenAt(message, this.clockLeads.get(this.clockOf(message))), now = Date.now();
36057
+ const group = type === "message" && message.linkId.startsWith("group:"), lateAt = group ? this.lateAt.get(message.linkId) : void 0;
34983
36058
  const attention = messageAttention(type, {
34984
36059
  ...message,
34985
- timestamp: writtenAt(message, this.clockLeads.get(message.linkId))
36060
+ timestamp: written
34986
36061
  }, {
34987
36062
  startedAt: this.feedbackStartedAt,
34988
36063
  newest,
34989
- now: Date.now()
36064
+ now,
36065
+ ...lateAt !== void 0 && { lateAt }
34990
36066
  });
36067
+ if (group && !message.event && now - written > 6e4) this.lateAt.set(message.linkId, now);
34991
36068
  if (attention) this.feedback(attention.type, message.linkId + ":" + message.id, message.linkId, attention.mention, attention.cue);
34992
36069
  }
34993
36070
  /**
@@ -35532,6 +36609,8 @@ var GhostlyNode = class GhostlyNode {
35532
36609
  }
35533
36610
  }
35534
36611
  });
36612
+ /** x401 credentials (WISP 3xx): kept for websites the headless CLI calls, never shared with a contact. */
36613
+ x401 = new X401Credentials();
35535
36614
  /** Identity proofs: this profile's, and those shared in each paired chat (WISP 300). */
35536
36615
  identities = new IdentityProofs({
35537
36616
  ledger: (linkId) => {
@@ -35593,6 +36672,59 @@ var GhostlyNode = class GhostlyNode {
35593
36672
  return this.transport.publishPayload(pubKeyZ32, payload, { background: true });
35594
36673
  }
35595
36674
  });
36675
+ /** The profile's contact link (WISP 8xx): one link that names nobody, its record kept on Pkarr while online. */
36676
+ contactLinks = new ContactLinks({
36677
+ online: () => this.networkOn,
36678
+ emit: () => this.emitState(),
36679
+ publish: (identity, records) => this.transport.publish(identity, records, { background: true }),
36680
+ resolve: async (pubKeyZ32) => (await this.transport.resolve(pubKeyZ32, { background: true }))?.records ?? null,
36681
+ ownInvite: (code) => {
36682
+ const params = decodeInviteCode(code);
36683
+ return !params || inviteOwnership(params, [...this.links.values()].map((l) => l.stored)).kind !== "new";
36684
+ },
36685
+ accept: async (request) => {
36686
+ const params = decodeInviteCode(request.invite);
36687
+ if (!params || inviteOwnership(params, [...this.links.values()].map((l) => l.stored)).kind !== "new") throw new Error("This request's invite is for a chat you already have.");
36688
+ const { linkId } = await this.joinLink({ inviteCode: request.invite });
36689
+ this.renameLink({
36690
+ linkId,
36691
+ label: request.name
36692
+ });
36693
+ await this.storeMessage({
36694
+ linkId,
36695
+ id: `peer_contact_${request.key.slice(0, 32)}`,
36696
+ text: request.text,
36697
+ sender: "peer",
36698
+ timestamp: Math.min(request.issued, request.receivedAt),
36699
+ via: "pkarr"
36700
+ });
36701
+ return linkId;
36702
+ }
36703
+ });
36704
+ /** Requests this profile sends through others' contact links (WISP 8xx). */
36705
+ contactAsks = new ContactAsks({
36706
+ online: () => this.networkOn,
36707
+ emit: () => this.emitState(),
36708
+ publish: (identity, records) => this.transport.publish(identity, records, { background: true }),
36709
+ resolve: async (pubKeyZ32) => (await this.transport.resolve(pubKeyZ32, { background: true }))?.records ?? null,
36710
+ createChat: async ({ label, text }) => {
36711
+ const chat = await this.createLink();
36712
+ if (label) this.renameLink({
36713
+ linkId: chat.linkId,
36714
+ label
36715
+ });
36716
+ await this.storeMessage({
36717
+ linkId: chat.linkId,
36718
+ id: `me_contact_${crypto.randomUUID()}`,
36719
+ text,
36720
+ sender: "me",
36721
+ timestamp: Date.now(),
36722
+ via: "pkarr"
36723
+ });
36724
+ return chat;
36725
+ },
36726
+ ownLink: (linkKeyZ32) => this.contactLinks.owns(linkKeyZ32)
36727
+ });
35596
36728
  /** The Nostr social layer (profile, follows, notes, publication) on top of verified Nostr proofs. */
35597
36729
  nostrSocial = new NostrSocial({
35598
36730
  settings: () => this.settings.nostr,
@@ -35795,13 +36927,15 @@ var GhostlyNode = class GhostlyNode {
35795
36927
  const live = this.links.get(linkId);
35796
36928
  return !!live?.lastSyncAt && !!live.presence?.online && presenceSeenAt(live.presence) > live.lastSyncAt;
35797
36929
  },
35798
- publish: (identity, records, background, door) => this.groupTransport.publish(identity, records, {
36930
+ publish: (identity, records, background, door, back) => this.groupTransport.publish(identity, records, {
35799
36931
  background,
35800
- door
36932
+ door,
36933
+ back
35801
36934
  }),
35802
- resolve: async (pubKeyZ32, background, door) => (await this.groupTransport.resolve(pubKeyZ32, {
36935
+ resolve: async (pubKeyZ32, background, door, back) => (await this.groupTransport.resolve(pubKeyZ32, {
35803
36936
  background,
35804
- door
36937
+ door,
36938
+ back
35805
36939
  }))?.records ?? null,
35806
36940
  expectPeer: (linkId) => this.links.get(linkId)?.link?.expectPeer(),
35807
36941
  signalIn: (linkId, payload, direct) => !!this.links.get(linkId)?.carried?.accept(payload, direct),
@@ -35815,7 +36949,7 @@ var GhostlyNode = class GhostlyNode {
35815
36949
  else this.closeGroupLink(linkId).catch(() => {});
35816
36950
  },
35817
36951
  edgeNick: (linkId) => this.links.get(linkId)?.presence.nick || void 0,
35818
- storeMessage: (message) => this.storeNewMessage(message),
36952
+ storeMessage: (message, live, place) => this.storeNewMessage(message, live, place),
35819
36953
  completeMessage: (message) => this.completeGroupMessage(message),
35820
36954
  forgetFrame: async (linkId, id) => {
35821
36955
  await db.patchMessage(linkId, id, (stored) => stored.frame ? { frame: void 0 } : null, true);
@@ -35824,6 +36958,7 @@ var GhostlyNode = class GhostlyNode {
35824
36958
  await db.patchMessage(linkId, id, () => ({ frame }), true);
35825
36959
  },
35826
36960
  placeAgain: (linkId, ids) => this.placeAgain(linkId, ids),
36961
+ placeAbove: (linkId, id) => this.placeAbove(linkId, id),
35827
36962
  membersChanged: (groupId) => this.groupMembersChanged(groupId),
35828
36963
  historyGone: (groupId) => this.groupHistoryGone(groupId),
35829
36964
  emit: () => this.emitState(),
@@ -36238,34 +37373,44 @@ var GhostlyNode = class GhostlyNode {
36238
37373
  await this.did.load();
36239
37374
  setOwnDidSource(() => this.did.id);
36240
37375
  this.did.start();
37376
+ await this.contactLinks.load();
37377
+ this.contactLinks.start();
37378
+ await this.contactAsks.load();
37379
+ this.contactAsks.start();
36241
37380
  await this.nostrSocial.load();
36242
37381
  await this.publicProfiles.load();
36243
37382
  if (!this.limitedMode) await this.startWallets();
36244
37383
  const history = /* @__PURE__ */ new Map();
36245
- for (const stored of await db.getLinks()) {
36246
- if (!stored.group) await this.settleHistory(stored.id);
36247
- const messages = stored.group ? [] : await db.getMessages(stored.id);
36248
- const storedFiles = stored.group ? [] : await fileStore.listForLink(stored.id);
37384
+ const links = await db.getLinks();
37385
+ for (const stored of links) history.set(stored.id, []);
37386
+ for (const stored of links) if (stored.group) this.links.set(stored.id, newLiveLink(stored, 0, linkFilesFrom(stored.id, [], [])));
37387
+ for (const group of await db.getGroups()) await this.settleHistory(`group:${group.id}`);
37388
+ await this.groups.load(this.limitedMode);
37389
+ this.groupsLoaded = true;
37390
+ this.groupsLoadedNow();
37391
+ this.emitState();
37392
+ for (const stored of links) {
37393
+ if (stored.group) continue;
37394
+ await this.settleHistory(stored.id);
37395
+ const messages = await db.getMessages(stored.id);
37396
+ const storedFiles = await fileStore.listForLink(stored.id);
36249
37397
  const waiting = new Set(messages.flatMap((m) => m.sender === "me" && m.delivery === "waiting" && m.file ? [m.file.id] : []));
36250
37398
  for (const file of storedFiles) if (file.transfer && file.transfer.state !== "done" && !file.wire3 && !waiting.has(file.id)) this.transfers.set(file.id, file.transfer.state === "transferring" ? {
36251
37399
  ...file.transfer,
36252
37400
  state: "failed",
36253
37401
  error: "Transfer interrupted. Retry when connected."
36254
37402
  } : file.transfer);
36255
- if (!stored.group) this.fileDesk.restore(stored.id, storedFiles);
37403
+ this.fileDesk.restore(stored.id, storedFiles);
36256
37404
  const files = linkFilesFrom(stored.id, storedFiles, messages);
36257
37405
  this.links.set(stored.id, newLiveLink(stored, messages[messages.length - 1]?.timestamp ?? 0, files));
36258
- if (!stored.group) this.links.get(stored.id).waitingSends = Promise.resolve(new Set(messages.filter(waitsForLive).map((m) => m.id)));
37406
+ this.links.get(stored.id).waitingSends = Promise.resolve(new Set(messages.filter(waitsForLive).map((m) => m.id)));
36259
37407
  history.set(stored.id, messages);
36260
37408
  for (const message of messages.slice(-20)) this.clockSample(message);
36261
37409
  const note = latestReaction(messages);
36262
37410
  if (note) this.reactionNotes.set(stored.id, note);
36263
- if (stored.profile && !stored.group) await this.outboxFor(stored.id).recover();
37411
+ if (stored.profile) await this.outboxFor(stored.id).recover();
36264
37412
  }
36265
37413
  this.transfersRestored = true;
36266
- for (const group of await db.getGroups()) await this.settleHistory(`group:${group.id}`);
36267
- await this.groups.load(this.limitedMode);
36268
- this.groupsLoaded = true;
36269
37414
  for (const group of this.groups.views()) {
36270
37415
  const note = latestReaction(await db.getMessages(`group:${group.id}`));
36271
37416
  if (note) this.reactionNotes.set(`group:${group.id}`, note);
@@ -36657,14 +37802,16 @@ var GhostlyNode = class GhostlyNode {
36657
37802
  /**
36658
37803
  * Says goodbye on every live session (`paired-bye`): the contacts end theirs now and watch for this app to come back,
36659
37804
  * instead of noticing a minute later. Synchronous: an app closing may have no time for anything after it.
37805
+ * `quitting`: the app quits (not a page that may be reloading), so a contact's call still ringing is declined too.
36660
37806
  */
36661
- depart() {
36662
- for (const live of this.links.values()) live.link?.depart();
37807
+ depart(options = {}) {
37808
+ for (const live of this.links.values()) live.link?.depart(options);
36663
37809
  }
36664
37810
  /**
36665
37811
  * Tells every peer we are leaving. Best effort: the browser may already be closing. `quiet`: says nothing to anyone
36666
37812
  * (no goodbye, no departure in the links' packets), as a device another device replaced must (WISP 06 § When a
36667
- * device finds itself superseded: "Nothing is said to contacts and nothing more is published").
37813
+ * device finds itself superseded: "Nothing is said to contacts and nothing more is published"). `quitting`: the app
37814
+ * itself quits (the Desktop app, a CLI daemon), not a web page that may be reloading (`depart`).
36668
37815
  */
36669
37816
  async shutdown(options = {}) {
36670
37817
  const quiet = options.quiet === true || this.gatedOut;
@@ -36675,7 +37822,7 @@ var GhostlyNode = class GhostlyNode {
36675
37822
  this.limitedTimer = null;
36676
37823
  }
36677
37824
  this.appShelf?.stop();
36678
- if (!quiet) this.depart();
37825
+ if (!quiet) this.depart({ quitting: options.quitting === true });
36679
37826
  const linksStopped = Promise.allSettled([...this.links.values()].map(async (live) => {
36680
37827
  live.carried?.stop();
36681
37828
  await Promise.all([live.link?.stop(!quiet), live.caps?.stop()]);
@@ -36709,6 +37856,8 @@ var GhostlyNode = class GhostlyNode {
36709
37856
  this.restoreTimer = null;
36710
37857
  this.identities.stop();
36711
37858
  this.did.stop();
37859
+ this.contactLinks.stop();
37860
+ this.contactAsks.stop();
36712
37861
  this.nostrSocial.stop();
36713
37862
  this.publicProfiles.stop();
36714
37863
  this.publicActivity.clear();
@@ -36746,6 +37895,7 @@ var GhostlyNode = class GhostlyNode {
36746
37895
  const withdraw = Promise.allSettled([
36747
37896
  this.hold.withdrawAll(),
36748
37897
  this.did.deactivate(),
37898
+ this.contactLinks.withdraw(),
36749
37899
  this.identities.revokeAll()
36750
37900
  ]);
36751
37901
  await Promise.race([withdraw, new Promise((resolve) => setTimeout(resolve, CLEAR_WITHDRAW_MS))]);
@@ -36874,6 +38024,9 @@ var GhostlyNode = class GhostlyNode {
36874
38024
  payments: this.desk.views(),
36875
38025
  identityProofs: this.identities.views(),
36876
38026
  did: this.did.view(),
38027
+ contactLink: this.contactLinks.view(),
38028
+ contactRequests: this.contactLinks.requests(),
38029
+ contactAsks: this.contactAsks.views(),
36877
38030
  nostr: this.nostrSocial.state(),
36878
38031
  edges: [...[...this.links.values()].filter((live) => live.stored.group && !live.stored.groupEntry).map((live) => this.viewOf(live)), ...this.communityPayViews(groups)],
36879
38032
  groups: groups.map((group) => ({
@@ -37185,9 +38338,42 @@ var GhostlyNode = class GhostlyNode {
37185
38338
  recheckIdentityProof(params) {
37186
38339
  return this.identities.recheck(params);
37187
38340
  }
38341
+ newX401HolderKey() {
38342
+ return this.x401.newKey();
38343
+ }
38344
+ addX401Credential(params) {
38345
+ return this.x401.add(params);
38346
+ }
38347
+ x401Credentials(params = {}) {
38348
+ return this.x401.list(params);
38349
+ }
38350
+ removeX401Credential(params) {
38351
+ return this.x401.remove(params);
38352
+ }
37188
38353
  setDidListed(params) {
37189
38354
  return this.did.setListed(params);
37190
38355
  }
38356
+ /** A new contact link for the profile; the one before is turned off. */
38357
+ createContactLink() {
38358
+ return this.contactLinks.create();
38359
+ }
38360
+ turnOffContactLink() {
38361
+ return this.contactLinks.turnOff();
38362
+ }
38363
+ /** The contact link was shown or copied, or Requests opened: look for requests now and often for a while. */
38364
+ contactLinkShown() {
38365
+ this.contactLinks.shown();
38366
+ }
38367
+ acceptContactRequest({ id }) {
38368
+ return this.contactLinks.accept(id);
38369
+ }
38370
+ declineContactRequest({ id }) {
38371
+ return this.contactLinks.decline(id);
38372
+ }
38373
+ /** Sends a request through someone's contact link; the chat made for it. */
38374
+ askContactLink(params) {
38375
+ return this.contactAsks.ask(params);
38376
+ }
37191
38377
  lookupIdentityDisplay(params) {
37192
38378
  const r = this.links.get(params.linkId)?.stored.identities?.received.find((x) => x.id === params.id);
37193
38379
  if (r?.binding.provider === "nostr") return this.nostrSocial.loadContact({
@@ -37519,6 +38705,8 @@ var GhostlyNode = class GhostlyNode {
37519
38705
  groupHistoryGone(groupId) {
37520
38706
  const linkId = `group:${groupId}`;
37521
38707
  this.newestAt.delete(linkId);
38708
+ this.lateAt.delete(linkId);
38709
+ for (const clock of this.clockLeads.keys()) if (clock.startsWith(`${linkId} `)) this.clockLeads.delete(clock);
37522
38710
  this.placedAt.delete(linkId);
37523
38711
  this.reactionNotes.delete(linkId);
37524
38712
  this.reactionNotesBefore.delete(linkId);
@@ -37682,6 +38870,7 @@ var GhostlyNode = class GhostlyNode {
37682
38870
  this.nostrSocial.forgetLink(linkId);
37683
38871
  this.hold.forgetLink(linkId);
37684
38872
  this.fileDesk.drop(linkId);
38873
+ this.contactAsks.forgetChat(linkId).catch(() => {});
37685
38874
  this.wakeLimiter.reset(linkId);
37686
38875
  this.callWakeLimiter.reset(linkId);
37687
38876
  if (live.stored.wakeToken) this.rotateWake().then(() => this.emitState());
@@ -38262,7 +39451,7 @@ var GhostlyNode = class GhostlyNode {
38262
39451
  if (!queue) {
38263
39452
  queue = new EditQueue({
38264
39453
  read: () => db.getMessages(linkId),
38265
- row: (id) => db.getMessage(linkId, id),
39454
+ rows: (ids) => db.getMessagesById(linkId, ids),
38266
39455
  ready: () => {
38267
39456
  const live = this.links.get(linkId), link = live?.link;
38268
39457
  return !!link && (link.supportsEdits || link.textDelivery === "dht" && this.takesDhtEdits(live));
@@ -39667,7 +40856,7 @@ var GhostlyNode = class GhostlyNode {
39667
40856
  * as a new message of mine, in the order they were written, carrying one more hop than it had and nothing of who wrote
39668
40857
  * it or where. A text goes as written; a file from the bytes on this device (a copy per chat, nothing fetched again),
39669
40858
  * through the chat's own path, so a large one still waits for the contact's consent. A group takes texts only. What one
39670
- * chat refuses does not stop the others: each says what it got.
40859
+ * chat refuses does not stop the others, nor the rest of its own: each says what it got, and which of them (`sent`).
39671
40860
  */
39672
40861
  async forwardMessages(params) {
39673
40862
  const { linkId } = params;
@@ -39678,7 +40867,7 @@ var GhostlyNode = class GhostlyNode {
39678
40867
  if (ids.length > 30) throw new Error(`Forward at most 30 messages at once`);
39679
40868
  if (!targets.length) throw new Error("No chat to forward to");
39680
40869
  if (targets.length > 5) throw new Error(`Forward to at most 5 chats at once`);
39681
- 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");
40870
+ if (linkId.startsWith("group:") ? !this.groups.views().some((g) => g.id === linkId.slice(6)) : this.links.get(linkId)?.stored.group !== void 0 || !this.links.has(linkId)) throw new Error("Chat not found");
39682
40871
  const chosen = await Promise.all(ids.map((id) => db.getMessage(linkId, id)));
39683
40872
  if (chosen.some((m) => !m)) throw new Error("That message is not in this chat");
39684
40873
  const messages = chosen.sort((a, b) => a.timestamp - b.timestamp);
@@ -39693,6 +40882,7 @@ var GhostlyNode = class GhostlyNode {
39693
40882
  const result = {
39694
40883
  to,
39695
40884
  messageIds: [],
40885
+ sent: [],
39696
40886
  error: null
39697
40887
  };
39698
40888
  results.push(result);
@@ -39714,11 +40904,20 @@ var GhostlyNode = class GhostlyNode {
39714
40904
  text: message.text,
39715
40905
  timestamp: now()
39716
40906
  });
39717
- if (sent.error) note(sent.error);
39718
- else if (sent.messageId) result.messageIds.push(sent.messageId);
40907
+ if (sent.error) {
40908
+ note(sent.error);
40909
+ continue;
40910
+ }
40911
+ if (sent.messageId) result.messageIds.push(sent.messageId);
40912
+ result.sent.push(message.id);
39719
40913
  continue;
39720
40914
  }
39721
40915
  if (groupId) {
40916
+ const check = this.groups.fileCheck(groupId);
40917
+ if (check.error) {
40918
+ note(check.error);
40919
+ continue;
40920
+ }
39722
40921
  const original = message.file, file = {
39723
40922
  id: groupFileId(groupId, "out"),
39724
40923
  name: original.name,
@@ -39742,7 +40941,10 @@ var GhostlyNode = class GhostlyNode {
39742
40941
  if (sent.error) {
39743
40942
  await removeStored(file.id).catch(() => {});
39744
40943
  note(sent.error);
39745
- } else if (sent.messageId) result.messageIds.push(sent.messageId);
40944
+ continue;
40945
+ }
40946
+ if (sent.messageId) result.messageIds.push(sent.messageId);
40947
+ result.sent.push(message.id);
39746
40948
  continue;
39747
40949
  }
39748
40950
  if (!live.link) {
@@ -39778,8 +40980,9 @@ var GhostlyNode = class GhostlyNode {
39778
40980
  throw error;
39779
40981
  }
39780
40982
  result.messageIds.push(`me_${timestamp}`);
40983
+ result.sent.push(message.id);
39781
40984
  } catch (error) {
39782
- note(error instanceof Error ? error.message : String(error));
40985
+ note(forwardError(error));
39783
40986
  }
39784
40987
  }
39785
40988
  }
@@ -40638,12 +41841,18 @@ var GhostlyNode = class GhostlyNode {
40638
41841
  const n = this.net(network);
40639
41842
  return via === "cashu" ? this.wallet.quoteInvoice(invoice, n) : this.lightningCard(n, card).then((c) => c.quote(invoice));
40640
41843
  }
40641
- /** `confirmedReal`: the person confirmed a Mainnet payment as real money; without it one is refused. */
40642
- async walletPayQuote({ quote, mint, note, confirmedReal }) {
41844
+ /**
41845
+ * `confirmedReal`: the person confirmed a Mainnet payment as real money; without it one is refused. `linkId` +
41846
+ * `invoice`: the chat whose message held the invoice. Paid on Testnet, its contact is told (`sayInvoicePaid`): a test
41847
+ * mint's invoice counts only on the payer's word.
41848
+ */
41849
+ async walletPayQuote({ quote, mint, note, confirmedReal, linkId, invoice }) {
40643
41850
  const network = WALLET_NETWORKS.find((n) => this.lightnings[n].withQuote(quote));
40644
41851
  const lightning = network && this.lightnings[network].withQuote(quote);
40645
41852
  assertConfirmedReal(network ?? mintNetwork(mint), confirmedReal);
40646
- return { paid: lightning ? await lightning.pay(quote, { note }) : await this.wallet.payQuote(quote, mint, note) };
41853
+ const paid = lightning ? await lightning.pay(quote, { note }) : await this.wallet.payQuote(quote, mint, note);
41854
+ if (paid && linkId && invoice && (network ?? mintNetwork(mint)) === "testnet") this.desk.sayInvoicePaid(linkId, invoice);
41855
+ return { paid };
40647
41856
  }
40648
41857
  /** A Lightning address or LNURL (LUD-16, LUD-06): resolved here, in the engine, so every platform fetches the same way. */
40649
41858
  lnurlResolve({ text, network }) {
@@ -40772,8 +41981,8 @@ var GhostlyNode = class GhostlyNode {
40772
41981
  bitcoinRefresh(params) {
40773
41982
  return this.bitcoins[this.net(params?.network)].sources.refresh();
40774
41983
  }
40775
- walletInspectCashu({ text }) {
40776
- return { inspection: this.wallet.inspect(text) };
41984
+ async walletInspectCashu({ text }) {
41985
+ return { inspection: await this.wallet.inspect(text) };
40777
41986
  }
40778
41987
  async walletReceiveToken({ token }) {
40779
41988
  const { amount } = await this.wallet.receiveToken(token.trim());
@@ -42316,7 +43525,8 @@ var GhostlyNode = class GhostlyNode {
42316
43525
  carry: (payload) => this.groups.carrySignal(group, peer, payload),
42317
43526
  open: () => !!live.link?.isDataLinkOpen,
42318
43527
  look: () => live.link?.look(),
42319
- direct: (payload) => this.groups.directSignal(group, peer, linkId, payload)
43528
+ direct: (payload) => this.groups.directSignal(group, peer, linkId, payload),
43529
+ after: live.carried?.lastDated()
42320
43530
  });
42321
43531
  const waiting = live.carried && this.groups.takeSignal(group, peer);
42322
43532
  if (waiting) live.carried.accept(waiting);
@@ -42581,6 +43791,7 @@ var GhostlyNode = class GhostlyNode {
42581
43791
  } : void 0;
42582
43792
  const resumed = stored.pairedPeerKey && stored.deliveryMode !== "dht" ? resumeHere(this.transportLogOf(live), this.transportsHere(), stored.peerTransports ?? live.caps?.peer?.transports) : {};
42583
43793
  let dhtShown;
43794
+ let wasTaken = false;
42584
43795
  live.link = new GhostLink({
42585
43796
  paymentMethods: stored.paymentMethods,
42586
43797
  paymentNetworks: this.chatNetworks(stored),
@@ -42670,6 +43881,7 @@ var GhostlyNode = class GhostlyNode {
42670
43881
  throw error;
42671
43882
  }
42672
43883
  if (!previous.pairedPeerKey) {
43884
+ if (!live.stored.group && live.stored.deliveryMode !== "dht") this.ensureNativeEndpoints(stored.id);
42673
43885
  live.caps?.update().catch(() => {});
42674
43886
  live.caps?.refresh(true);
42675
43887
  }
@@ -42709,6 +43921,11 @@ var GhostlyNode = class GhostlyNode {
42709
43921
  onDhtDelivery: (view) => {
42710
43922
  if (stored.profile && !stored.group) this.outboxes.get(linkId)?.flush().catch(() => {}).then(() => this.editQueues.get(linkId)?.flush()).catch(() => {});
42711
43923
  this.observeTransport(linkId);
43924
+ const taken = !!live.link?.takenFromUs;
43925
+ if (taken !== wasTaken) {
43926
+ wasTaken = taken;
43927
+ if (!taken && live.stored.deliveryMode !== "dht" && this.keepsNativeEndpoints(linkId, live.stored)) this.ensureNativeEndpoints(linkId);
43928
+ }
42712
43929
  const shown = JSON.stringify(view);
42713
43930
  if (shown !== dhtShown) {
42714
43931
  dhtShown = shown;
@@ -43076,7 +44293,7 @@ var GhostlyNode = class GhostlyNode {
43076
44293
  get nativeFactories() {
43077
44294
  const relay = this.relaysHyperdht ? this.hyperdhtRelay : "";
43078
44295
  return {
43079
- ...relay ? { "hyperdht/1": async (seedB64) => (await import("./hyperdhtRelay-De5FT7Am.js")).createRelayedHyperEndpoint(seedB64, relay) } : {},
44296
+ ...relay ? { "hyperdht/1": async (seedB64) => (await import("./hyperdhtRelay-13aQ4KOf.js")).createRelayedHyperEndpoint(seedB64, relay) } : {},
43080
44297
  ...this.options.nativeTransports,
43081
44298
  ...this.ownIrohRelays ? { "iroh/1": (seedB64) => this.options.nativeTransports["iroh/1"](seedB64, this.settings.irohRelays?.length ? { relays: [...this.settings.irohRelays] } : {}) } : {},
43082
44299
  ...this.options.irohWeb ? { "iroh/1": (seedB64) => createIrohWebEndpoint(seedB64, { relays: this.irohRelays }) } : {}
@@ -43206,7 +44423,7 @@ var GhostlyNode = class GhostlyNode {
43206
44423
  */
43207
44424
  async startNativeEndpoint(linkId, expected, key, factory, inUse) {
43208
44425
  const live = this.links.get(linkId), link = live?.link;
43209
- if (this.shuttingDown || !live?.stored.profile || live.stored.deliveryMode === "dht" || !link || link !== expected) return false;
44426
+ if (this.shuttingDown || !live?.stored.profile || live.stored.deliveryMode === "dht" || !link || link !== expected || link.takenFromUs) return false;
43210
44427
  const group = !!live.stored.group;
43211
44428
  if (group && !this.keepsGroupNative(live.stored)) return false;
43212
44429
  if (link.availableTransports.includes(key)) return false;
@@ -43265,7 +44482,7 @@ var GhostlyNode = class GhostlyNode {
43265
44482
  starting.then((late) => late.close(), () => {});
43266
44483
  return false;
43267
44484
  }
43268
- if (this.shuttingDown || live.stored.deliveryMode === "dht" || live.link !== link || !this.links.has(linkId)) {
44485
+ if (this.shuttingDown || live.stored.deliveryMode === "dht" || live.link !== link || !this.links.has(linkId) || link.takenFromUs) {
43269
44486
  await endpoint.close();
43270
44487
  return false;
43271
44488
  }
@@ -43446,8 +44663,9 @@ var GhostlyNode = class GhostlyNode {
43446
44663
  * ahead of this clock nor above a row placed here since the app started. The admin's clock picks that time: two
43447
44664
  * minutes ahead, "X joined" sat under everything said in the next two minutes; behind, it went above what the
43448
44665
  * members had just read. Read late with nothing newer here (a member back after a while), it keeps its moment.
44666
+ * `place`: a received row's place given already (`placeAbove`): it takes it, and the next row still goes past the last.
43449
44667
  */
43450
- placed(message) {
44668
+ placed(message, place) {
43451
44669
  const now = Date.now(), last = this.placedAt.get(message.linkId) ?? 0;
43452
44670
  if (message.event) {
43453
44671
  if (message.sender !== "peer") return message;
@@ -43462,10 +44680,10 @@ var GhostlyNode = class GhostlyNode {
43462
44680
  const sentAt = claimedTime(message.timestamp);
43463
44681
  message = {
43464
44682
  ...message,
43465
- timestamp: arrivalKey(now, last),
44683
+ timestamp: place ?? arrivalKey(now, last),
43466
44684
  ...sentAt !== void 0 && { sentAt }
43467
44685
  };
43468
- this.placedAt.set(message.linkId, message.timestamp);
44686
+ if (place === void 0) this.placedAt.set(message.linkId, message.timestamp);
43469
44687
  } else this.placedAt.set(message.linkId, Math.max(last, Math.min(message.timestamp, now)));
43470
44688
  return message;
43471
44689
  }
@@ -43487,11 +44705,32 @@ var GhostlyNode = class GhostlyNode {
43487
44705
  }));
43488
44706
  if (moved.length) await this.messagesChanged(linkId, moved.map((m) => m.id));
43489
44707
  }
44708
+ /**
44709
+ * A place just above this row of its history, below the one before it: where a group member's message that comes
44710
+ * after a later one of theirs is stored, so nothing has to move once the rest comes (WISP 400, requirement 10). At
44711
+ * most `AUTHOR_ORDER_ROOM` below the row, so the next older page finds room above it too; none when the two rows are
44712
+ * less than two milliseconds apart (it goes at the end, and is placed again).
44713
+ */
44714
+ async placeAbove(linkId, id) {
44715
+ const row = await db.getMessage(linkId, id);
44716
+ if (!row) return void 0;
44717
+ const floor = (await db.getMessagePage(linkId, {
44718
+ limit: 1,
44719
+ before: {
44720
+ timestamp: row.timestamp,
44721
+ id: row.id
44722
+ }
44723
+ })).messages[0]?.timestamp ?? -Infinity;
44724
+ return row.timestamp - floor < 2 ? void 0 : Math.max(floor + 1, row.timestamp - 64);
44725
+ }
43490
44726
  async storeMessage(message) {
43491
44727
  await this.storeNewMessage(message);
43492
44728
  }
43493
- /** Stores a message; false when it was there already (or deleted here): nothing new came. */
43494
- async storeNewMessage(message) {
44729
+ /**
44730
+ * Stores a message; false when it was there already (or deleted here): nothing new came. `straight`: a group's,
44731
+ * straight from its author (`clockSample`). `place`: as `placed`.
44732
+ */
44733
+ async storeNewMessage(message, straight = false, place) {
43495
44734
  const edge = this.links.get(message.linkId)?.stored;
43496
44735
  if (edge?.group && edge.groupPeer && !edge.groupEntry) message = {
43497
44736
  ...message,
@@ -43506,7 +44745,7 @@ var GhostlyNode = class GhostlyNode {
43506
44745
  id: `${message.linkId}:${message.id}`,
43507
44746
  ...message.sender === "peer" ? { member: pay.member } : {}
43508
44747
  };
43509
- message = this.placed(message);
44748
+ message = this.placed(message, place);
43510
44749
  const live = this.links.get(message.linkId);
43511
44750
  if (live?.stored.deletedIds?.includes(message.id)) return false;
43512
44751
  message = await this.resolveReply(message);
@@ -43536,9 +44775,9 @@ var GhostlyNode = class GhostlyNode {
43536
44775
  }
43537
44776
  if (!await db.addMessage(message)) return false;
43538
44777
  if (live && waitsForLive(message)) live.waitingSends?.then((known) => known.add(message.id), () => {});
43539
- this.clockSample(message);
44778
+ this.clockSample(message, straight);
43540
44779
  const newest = this.newestAt.get(message.linkId);
43541
- if (!message.event) this.newestAt.set(message.linkId, Math.max(newest ?? 0, writtenAt(message, this.clockLeads.get(message.linkId))));
44780
+ if (!message.event) this.newestAt.set(message.linkId, Math.max(newest ?? 0, writtenAt(message, this.clockLeads.get(this.clockOf(message)))));
43542
44781
  if (message.sender === "peer") this.messageFeedback("message", message, newest);
43543
44782
  if (live) live.lastMessageAt = Math.max(live.lastMessageAt, message.timestamp);
43544
44783
  const early = message.sender === "peer" && message.id.startsWith("peer_") ? this.editBuffer.take(message.linkId, message.id.slice(5)) : void 0;
@@ -43805,8 +45044,8 @@ var EngineServer = class {
43805
45044
  this.clients.delete(client);
43806
45045
  });
43807
45046
  }
43808
- stop() {
43809
- return this.node.shutdown();
45047
+ stop(options = {}) {
45048
+ return this.node.shutdown({ quitting: options.quitting === true });
43810
45049
  }
43811
45050
  detach(client) {
43812
45051
  this.clients.delete(client);
@@ -43831,13 +45070,13 @@ var EngineServer = class {
43831
45070
  params = { on: this.onCall.size > 0 };
43832
45071
  }
43833
45072
  try {
43834
- await this.ready;
45073
+ await (readsGroupHistory(request) ? Promise.race([this.ready, this.node.groupsRead]) : this.ready);
43835
45074
  const method = this.node[request.method];
43836
45075
  if (typeof method !== "function") throw new Error(`Unknown method: ${request.method}`);
43837
45076
  if (this.node.limited && !LIMITED_MODE_METHODS.has(String(request.method))) throw new Error(LIMITED_MODE_ERROR);
43838
45077
  response.result = await method.call(this.node, params);
43839
45078
  } catch (error) {
43840
- response.error = error instanceof Error ? error.message : String(error);
45079
+ response.error = errorLine(error);
43841
45080
  }
43842
45081
  try {
43843
45082
  client.post(response);
@@ -43914,6 +45153,12 @@ var EngineServer = class {
43914
45153
  for (const client of [...this.clients]) this.post(client, event);
43915
45154
  }
43916
45155
  };
45156
+ /** Whether a call only reads a group's stored history (`GhostlyNode.groupsRead`). */
45157
+ function readsGroupHistory(request) {
45158
+ if (request.method === "groupMessages") return true;
45159
+ const linkId = request.params?.linkId;
45160
+ return request.method === "messagePage" && typeof linkId === "string" && linkId.startsWith("group:");
45161
+ }
43917
45162
  /** A call signal's kind (`o` for an offer), read as the client reads it; checked in full before it is replayed. */
43918
45163
  function signalKind(signal) {
43919
45164
  try {
@@ -43925,4 +45170,4 @@ function signalKind(signal) {
43925
45170
  //#endregion
43926
45171
  export { EngineServer };
43927
45172
 
43928
- //# sourceMappingURL=server-p5DJnN7T.js.map
45173
+ //# sourceMappingURL=server-Cd1dLOB8.js.map