@ghostlytools/cli 1.0.1 → 1.0.3
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.
- package/README.md +10 -7
- package/dist/assets/bark_ffi_wasm_bg-EY2uc0Bx.js.map +1 -1
- package/dist/assets/bitcoindevkit_bg-B-iLWrmX.js.map +1 -1
- package/dist/assets/fileBytes-Br_MpeNT.js +123 -0
- package/dist/assets/fileBytes-Br_MpeNT.js.map +1 -0
- package/dist/assets/{fileBytes-CLv0gryb.js → fileBytes-CpP9Vj7s.js} +2 -2
- package/dist/assets/{fileBytes-CLv0gryb.js.map → fileBytes-CpP9Vj7s.js.map} +1 -1
- package/dist/assets/{fileBytesIdb-CKCtzMTy.js → fileBytesIdb-vubSXPoO.js} +3 -2
- package/dist/assets/{fileBytesIdb-CKCtzMTy.js.map → fileBytesIdb-vubSXPoO.js.map} +1 -1
- package/dist/assets/{fileBytesOpfs-By55XVTI.js → fileBytesOpfs-6iSa6u2K.js} +20 -8
- package/dist/assets/fileBytesOpfs-6iSa6u2K.js.map +1 -0
- package/dist/assets/{hyperdhtRelay-_rJsllcP.js → hyperdhtRelay-VM9STKLd.js} +3 -3
- package/dist/assets/{hyperdhtRelay-_rJsllcP.js.map → hyperdhtRelay-VM9STKLd.js.map} +1 -1
- package/dist/assets/idb-DIN_Miwb.js +351 -0
- package/dist/assets/idb-DIN_Miwb.js.map +1 -0
- package/dist/assets/{mainline-B0mUxYPH.js → mainline-CcO2f8jy.js} +17 -10
- package/dist/assets/mainline-CcO2f8jy.js.map +1 -0
- package/dist/assets/restoredRows-tVxgyG23.js +79 -0
- package/dist/assets/restoredRows-tVxgyG23.js.map +1 -0
- package/dist/assets/{server-C-8wbsAX.js → server-BnNXYzR9.js} +2371 -358
- package/dist/assets/server-BnNXYzR9.js.map +1 -0
- package/dist/assets/src-7sdQpCoi.js +23665 -0
- package/dist/assets/src-7sdQpCoi.js.map +1 -0
- package/dist/ghostly.mjs +933 -23260
- package/dist/ghostly.mjs.map +1 -1
- package/package.json +2 -2
- package/dist/assets/fileBytesOpfs-By55XVTI.js.map +0 -1
- package/dist/assets/mainline-B0mUxYPH.js.map +0 -1
- package/dist/assets/server-C-8wbsAX.js.map +0 -1
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { a as fileBytesOf, c as removeFileBytes, d as __exportAll, i as fileBytes, n as blobDigest } from "./fileBytes-Br_MpeNT.js";
|
|
2
|
+
import { $a as createIdentity, $i as heardTime, $n as newerHead, $r as revokesIdentity, $t as isBitcoinAddress, Aa as LEGACY_SERVICES, Ai as GROUP_REACTION_FRAME, An as cashuRequestPayload, Ar as EDIT_CAPABILITY, At as didDhtFromPublicKey, Ba as currentRelays, Bi as pairedReplyAuthor, Bn as MESH_HUBS, Br as capsDescriptors, Bt as SEPOLIA_TEST_USDT_FAUCET, C as groupWakeFrame, Ci as EDIT_RATE_WINDOW_MS, Cr as WakeLimiter, D as GROUP_READ_NOTE, Di as forwardedAgain, Dn as engineText, Dr as checkPushEndpoint, Ea as parseCallSignal, Ei as parseLinkPreview, En as engineError, Er as wakeRequest, F as mentionsMember, Fa as encodeInviteCode, Fi as reactionEmoji, Fn as parseFedimintRequestPayload, Fr as HoldRefusedError, Ft as ETHEREUM_USDT, Ga as isDiscoveryBudgetError, Gn as beaconKeys, Gt as assertWholeSats, Hi as replySnippet, Hn as meshHubs, Ht as USDT_PUBLIC_RPC, I as groupName, Ii as reactionIsNewer, In as GhostLink, Ir as newMailbox, It as EVM_TEST_CHAINS, Ji as checkStatusCard, Jn as freshHubs, Jr as emptyIdentityLedger, Jt as walletNetworkOf, Ka as withRequestOptions, Ki as STATUS_CARD_LIMITS, Kn as beaconRecords, Kr as IdentityExchange, Kt as formatPaymentAmount, La as inviteOwnership, Li as readReaction, Ln as INVITE_TAKEN, Lr as readManifest, Lt as ONCHAIN_PROVIDER, M as GROUP_EDIT_TEXT_BYTES, Ma as OwnInviteError, Mi as ReactionWindow, Mn as findEndpoint, Mr as HOLD_DATED_BACK_MS, Mt as encodeDidDhtPacket, N as carryMentions, Na as createChatInvite, Ni as nextReactionNumber, Nn as isFederationId, Nr as HOLD_LIMITS, O as GroupSession, Oa as emptyLinkRecords, Oi as readForwarded, On as decodeBolt11, Or as vapidKeysMatch, P as parseCommunityEdit, Pa as decodeInviteCode, Pi as queueReaction, Pn as parseCashuRequestPayload, Pr as HoldKeys, Pt as signDidDhtPacket, Qi as claimedTime, Qn as mergeLobby, Qr as receivedIdentityStatus, Qt as sparkInvoiceDetails, Ra as DEFAULT_RELAYS, Ri as validReactionNumber, Rn as LIVENESS_PING_MS, Rr as CapsExchange, Rt as PaymentPreflightError, S as GroupWakeLimiter, Si as EDIT_BUFFER_MS, Sr as WAKE_CALL_INTERVAL_MS, T as parseGroupWakeFrame, Ti as dhtEditId, Tr as relayRequest, Ua as traceLink, Ui as BUTTON_ID, Un as pickMeshHubs, Ut as WALLET_NETWORKS, Va as normalizeRelayUrl, Vi as readReply, Vn as mayBeHub, Vr as dialDescriptors, Vt as TEST_USDT_FAUCET_AMOUNT, Wa as budgetRetryMs, Wn as COMMUNITY_TOPOLOGY, Wr as IDENTITY_REVOCATION_LABEL, Wt as assertTokenUnits, Xi as statusCardText, Xn as lobbyRecords, Xr as identityStatement, Xt as SPARK_PROVIDER, Ya as createRelayPayload, Yi as sameButtonText, Yn as lobbyKeys, Yr as identityRevocationValue, Yt as SPARK_NETWORKS, Zi as arrivalKey, Zn as mergeBeacon, Zr as newIdentityBinding, Zt as sparkAddressKind, _ as knockIdentity, _a as decodeControl, _i as parseVoiceMeta, _r as mayPin, a as sealGroupTyping, aa as rosterHas, ai as automaticTransport, ao as bytesEqual, ar as shouldBeHub, b as readKnocks, ba as RTC_CONFIG, bi as DHT_MESSAGE_TTL, br as pinNumberHolds, c as GROUP_READ_NOTE_COMMUNITY, ca as DirectPathWatch, ci as parseImageMeta, co as toBase64Url, cr as transferEnded, d as decodeCommunityLink, da as presenceSeenAt, do as utf8Encode, ea as receivedTimestamp, ei as lastSharedWithMe, eo as identityFromSeed, er as pickHubs, f as encodeCommunityLink, fa as GhostlyHttpError, fr as TypingSender, g as entryParams, ga as LIMITS, gi as fileMessageText, gr as PIN_LIMITS, h as encodeGroupEntryLink, ha as webLocalFetch, i as openGroupTyping, ia as MEMBER_KEY, ii as TRANSPORTS, io as sanitizeAvatar, ir as readLobby, ja as serviceIdFromName, ji as REACTION_LIMITS, jn as fedimintRequestPayload, jr as HOLD_CAPABILITY, jt as encodeDidDhtDeactivated, k as groupMessageId, ka as HTTP_SERVICE_PROTO, ki as COMMUNITY_REACTION_FRAME, kn as ENDPOINT, kt as didDhtDocument, l as communityHasFrame, lo as toZ32, lr as transferStalled, m as decodeGroupEntryLink, ma as parseLocalTarget, mr as typingActivity, n as GroupTypingBoard, na as GROUP_MEMBER_CAP, ni as emptyProofLedger, no as publicKeyFromZ32, nr as readBeacon, nt as isAtprotoDid, o as COMMUNITY_LIMITS, oa as verifyCommitSignature, oi as irohDescriptor, oo as fromBase64Url, or as ChatFiles, pa as formatLocalTarget, qa as PacketTooLargeError, qn as doorHubs, qt as validatePaymentTarget, r as groupTypingEpoch, ri as validExternalKey, rr as readBeaconHead, rt as normalizeAtprotoHandle, s as CommunitySession, sa as edgeParams, si as irohRelayUrl, so as randomBytes, sr as FILE_LIMITS, ta as shownTime, ti as PeerProofs, tn as isPubkyKey, to as identityFromSeedB64, tr as rankHubs, u as communityMessageId, ua as RELAY_POLL_INTERVALS, ui as safeBlobType, uo as utf8Decode, v as knockRecords, va as encodeControl, vr as nextPinNumber, w as groupWakes, wi as RateWindow, wr as newWakeToken, xi as peekDhtMailbox, xr as readPin, y as mergeKnocks, yi as parseVideoMeta, yr as pinIsNewer, za as RelayTransport, zi as wireReaction, zn as pairedMessageFrame, zr as DHT_TEXT_CAPABILITY, zt as SEPOLIA_TEST_USDT } from "./src-7sdQpCoi.js";
|
|
3
|
+
import { a as deleteDatabase, c as profileOpenFailure, d as wrap, i as databaseName, l as store, n as STORES, o as fileStore, r as clearProfileStores, s as openDb, t as CARD_INDEX, u as transact } from "./idb-DIN_Miwb.js";
|
|
4
|
+
import { $ as boundedIdentityFetch, A as TEST_MINT, B as registeredLightningProviders, C as networkLabel, D as WALLET_TYPES, F as paysItsOwnInvoices, G as SourceConfigError, H as reserveAdapterIds, I as BACKUP_MEDIA_TYPE, J as networkMode, K as SourceUnreachableError, L as MAX_BACKUP_BYTES, M as isTestMint, N as isWorthlessMint, O as BITCOIN_INVOICE_ON_TESTNET, P as mintNetwork, Q as setOwnDidSource, R as identityProviders, S as WrongNetworkError, T as cashuMint, U as NothingSpentError, V as registeredOnchainProviders, W as PROVIDER_PLATFORMS, X as assertPublicServer, Y as redact, Z as resolveAtprotoDid, _ as newDeviceKey, a as createFailure, at as replyRef, b as ModeChanged, c as paymentNetwork, ct as paymentWireId, d as networkReason, dt as removeStored, et as verifyIdentity, f as rpcHost, ft as storedSize, g as intentRepository, h as ProviderSources, i as assertConfirmedReal, it as receivedPairedReply, j as fakesLightning, k as DEFAULT_MINTS, l as paymentNetworksOf, lt as FileAppender, m as LightningCards, n as loadFedimintSdk, nt as groupReply, o as createTiming, ot as replyTo, p as CASHU_CARD, pt as streamStored, q as isNothingSpentError, r as WALLET_NAMES, rt as pairedWireReply, s as crossNetwork, st as paymentAlias, t as fedimintDatabase, tt as chosenResolver, u as walletInstances, ut as readStored, v as sealSeed, w as CASHU_MINT_SOURCE, x as ModeGate, y as unsealSeed, z as onAdaptersChanged } from "../ghostly.mjs";
|
|
2
5
|
import { base64, base64url, base64urlnopad, bech32, hex } from "@scure/base";
|
|
3
6
|
import { hkdf } from "@noble/hashes/hkdf.js";
|
|
4
7
|
import { sha256 } from "@noble/hashes/sha2.js";
|
|
@@ -12,11 +15,112 @@ import WalletManagerEvm from "@tetherto/wdk-wallet-evm";
|
|
|
12
15
|
import { Interface, Transaction, getAddress, keccak256 } from "ethers";
|
|
13
16
|
import { ArkAddress, EsploraProvider, IndexedDBContractRepository, IndexedDBWalletRepository, MnemonicIdentity, RestArkProvider, RestIndexerProvider, Transaction as Transaction$1, Wallet, assertAllowedSighashTypes, assertSubmittedArkTxid, canRecoverOnchain, isRecoverable, matchServerCheckpoints, verifyTapscriptSignatures } from "@arkade-os/sdk";
|
|
14
17
|
import { tapLeafHash } from "@scure/btc-signer/payment.js";
|
|
18
|
+
import { Amount, MeltChangeError, OutputData, Wallet as Wallet$1, decodePaymentRequest, deserializeSwapPreview, getEncodedToken, getTokenMetadata, isMintOperationError, serializeSwapPreview } from "@cashu/cashu-ts";
|
|
15
19
|
import { Transaction as Transaction$2 } from "@scure/btc-signer";
|
|
16
20
|
import { bytesToHex, concatBytes, hexToBytes, utf8ToBytes } from "@noble/hashes/utils.js";
|
|
17
21
|
import * as nip04 from "nostr-tools/nip04";
|
|
18
22
|
import * as nip44 from "nostr-tools/nip44";
|
|
19
|
-
|
|
23
|
+
/** Evidence older than this says nothing about the clock now. */
|
|
24
|
+
var CLOCK_EVIDENCE_MS = 18e5;
|
|
25
|
+
/** Sources kept of each kind: a profile has a few relays, and may have hundreds of contacts. */
|
|
26
|
+
var KEPT = 64;
|
|
27
|
+
/** -1: everything the sample allows is behind by more than `CLOCK_OFF_MS`; 1: ahead; 0: it does not say. */
|
|
28
|
+
var says = (sample) => sample.low > 6e4 ? 1 : sample.high < -6e4 ? -1 : 0;
|
|
29
|
+
var middle = (values) => {
|
|
30
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
31
|
+
return sorted[Math.floor((sorted.length - 1) / 2)];
|
|
32
|
+
};
|
|
33
|
+
/**
|
|
34
|
+
* The rule alone: how far this clock is from the others' (ms, positive when it is ahead) when the samples fresh at
|
|
35
|
+
* `now` say it is clearly off, else null.
|
|
36
|
+
*/
|
|
37
|
+
function clockOffset(servers, peers, now) {
|
|
38
|
+
const fresh = (samples) => [...samples.values()].filter((s) => now - s.at < CLOCK_EVIDENCE_MS);
|
|
39
|
+
const s = fresh(servers), p = fresh(peers);
|
|
40
|
+
const mid = (sample) => (sample.low + sample.high) / 2;
|
|
41
|
+
for (const way of [1, -1]) {
|
|
42
|
+
const serversOff = s.filter((x) => says(x) === way), peersOff = p.filter((x) => says(x) === way);
|
|
43
|
+
const serversNot = s.length - serversOff.length, peersNot = p.length - peersOff.length;
|
|
44
|
+
if (serversOff.length >= 2 && serversNot === 0) return Math.round(middle(serversOff.map(mid)));
|
|
45
|
+
if (serversNot > 0) continue;
|
|
46
|
+
if (serversOff.length === 1 && peersOff.length >= 1 && peersNot === 0) return Math.round(middle([...serversOff, ...peersOff].map(mid)));
|
|
47
|
+
if (peersOff.length >= 3 && peersOff.length >= 3 * peersNot) return Math.round(middle(peersOff.map(mid)));
|
|
48
|
+
}
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
var ClockWatch = class {
|
|
52
|
+
onChange;
|
|
53
|
+
now;
|
|
54
|
+
servers = /* @__PURE__ */ new Map();
|
|
55
|
+
peers = /* @__PURE__ */ new Map();
|
|
56
|
+
was = null;
|
|
57
|
+
timer = null;
|
|
58
|
+
/** `onChange`: what `offset` says changed (it appeared, went away, or moved by a minute). */
|
|
59
|
+
constructor(onChange = () => {}, now = Date.now) {
|
|
60
|
+
this.onChange = onChange;
|
|
61
|
+
this.now = now;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* A server's time: `date` (ms, to the second) from an answer to a request that went out at `sent` and came back at
|
|
65
|
+
* `received`, both by this clock.
|
|
66
|
+
*/
|
|
67
|
+
server(source, date, sent, received) {
|
|
68
|
+
if (!Number.isFinite(date) || date <= 0 || received < sent) return;
|
|
69
|
+
this.keep(this.servers, source, {
|
|
70
|
+
low: sent - date - 999,
|
|
71
|
+
high: received - date,
|
|
72
|
+
at: this.now()
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* A contact's packet, dated `packetAt` by its clock, that came between this device's reads of its record at
|
|
77
|
+
* `readBefore` and `readAt` (this clock). Says nothing when the two reads were far apart.
|
|
78
|
+
*/
|
|
79
|
+
peer(peer, packetAt, readBefore, readAt) {
|
|
80
|
+
if (!(readBefore > 0) || readAt < readBefore || readAt - readBefore > 15e3 || !Number.isFinite(packetAt)) return;
|
|
81
|
+
this.keep(this.peers, peer, {
|
|
82
|
+
low: readBefore - packetAt,
|
|
83
|
+
high: readAt - packetAt,
|
|
84
|
+
at: this.now()
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
keep(samples, source, sample) {
|
|
88
|
+
samples.delete(source);
|
|
89
|
+
samples.set(source, sample);
|
|
90
|
+
if (samples.size > KEPT) samples.delete(samples.keys().next().value);
|
|
91
|
+
this.settle();
|
|
92
|
+
}
|
|
93
|
+
/** This clock minus the others', in ms (positive: ahead), when it is clearly off; else null. */
|
|
94
|
+
get offset() {
|
|
95
|
+
return clockOffset(this.servers, this.peers, this.now());
|
|
96
|
+
}
|
|
97
|
+
/** The clock was set, or the evidence is of another time: nothing is known until the next answers. */
|
|
98
|
+
reset() {
|
|
99
|
+
this.servers.clear();
|
|
100
|
+
this.peers.clear();
|
|
101
|
+
this.settle();
|
|
102
|
+
}
|
|
103
|
+
/** Stops the timer that lets the evidence age out. */
|
|
104
|
+
close() {
|
|
105
|
+
if (this.timer) clearTimeout(this.timer);
|
|
106
|
+
this.timer = null;
|
|
107
|
+
}
|
|
108
|
+
settle() {
|
|
109
|
+
this.close();
|
|
110
|
+
for (const samples of [this.servers, this.peers]) for (const [source, sample] of samples) if (this.now() - sample.at >= 18e5) samples.delete(source);
|
|
111
|
+
const offset = this.offset;
|
|
112
|
+
if (offset !== null) {
|
|
113
|
+
const oldest = Math.min(...[...this.servers.values(), ...this.peers.values()].map((s) => s.at));
|
|
114
|
+
this.timer = setTimeout(() => this.settle(), Math.max(1e3, oldest + CLOCK_EVIDENCE_MS - this.now()));
|
|
115
|
+
this.timer.unref?.();
|
|
116
|
+
}
|
|
117
|
+
const reads = (value) => value === null ? null : Math.round(value / 6e4);
|
|
118
|
+
if (reads(offset) === reads(this.was)) return;
|
|
119
|
+
this.was = offset;
|
|
120
|
+
this.onChange(offset);
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
//#endregion
|
|
20
124
|
//#region ../browser/src/engine/paymentAdapters/walletNetworks.ts
|
|
21
125
|
/** Where the wallet of one network is stored. The same key an older app parks it under, so it finds it too. */
|
|
22
126
|
var walletKey = (rail, network) => `${rail}-mode-${network}`;
|
|
@@ -3960,26 +4064,59 @@ var CashuAdapter = class {
|
|
|
3960
4064
|
token = await this.wallet.executeReviewedCashu(review, prepared);
|
|
3961
4065
|
} catch (error) {
|
|
3962
4066
|
prepared.attemptEndedAt = Date.now();
|
|
4067
|
+
if (persist && isMintOperationError(error) && await this.wallet.reviewedCashuSpentElsewhere(prepared).catch(() => false)) return this.spentElsewhere(prepared, persist);
|
|
3963
4068
|
throw error;
|
|
3964
4069
|
}
|
|
3965
4070
|
await persist?.();
|
|
3966
4071
|
await this.publish(review, token);
|
|
3967
|
-
return
|
|
4072
|
+
return await this.outcome(review, prepared) ?? { settled: false };
|
|
4073
|
+
}
|
|
4074
|
+
/**
|
|
4075
|
+
* What the mint and this wallet know of a payment whose token went out: paid once the mint reads its ecash spent,
|
|
4076
|
+
* unless this wallet is the one that spent it, taking the payment back. Asked in that order: ecash taken back while
|
|
4077
|
+
* the mint was being asked reads spent too. Undefined while the contact has not taken it.
|
|
4078
|
+
*/
|
|
4079
|
+
async outcome(review, prepared) {
|
|
4080
|
+
const spent = await this.wallet.reviewedCashuSpent(prepared);
|
|
4081
|
+
if (await this.wallet.reviewedCashuTakenBack(review.id)) return {
|
|
4082
|
+
settled: false,
|
|
4083
|
+
failed: true,
|
|
4084
|
+
error: engineText("paymentTakenBack")
|
|
4085
|
+
};
|
|
4086
|
+
return spent ? { settled: true } : void 0;
|
|
3968
4087
|
}
|
|
3969
4088
|
/** `persist` saves the intent as it is: required, since the abandon path must be durable before it releases anything. */
|
|
3970
4089
|
async reconcile(review, prepared, persist) {
|
|
3971
4090
|
if (prepared.abandoned) return this.abandon(prepared, persist);
|
|
3972
|
-
if (
|
|
4091
|
+
if (prepared.spentElsewhere) return this.spentElsewhere(prepared, persist);
|
|
4092
|
+
const known = await this.outcome(review, prepared);
|
|
4093
|
+
if (known) return known;
|
|
3973
4094
|
const token = prepared.token ?? await this.wallet.recoverReviewedCashu(review, prepared);
|
|
3974
4095
|
if (token) await this.publish(review, token);
|
|
3975
|
-
|
|
4096
|
+
const after = await this.outcome(review, prepared);
|
|
4097
|
+
if (after) return after;
|
|
3976
4098
|
if (!token && Date.now() >= swapSettledAt(review, prepared) && await this.wallet.reviewedCashuNeverSwapped(prepared)) return this.abandon(prepared, persist);
|
|
4099
|
+
if (!token && await this.wallet.reviewedCashuSpentElsewhere(prepared)) return this.spentElsewhere(prepared, persist);
|
|
3977
4100
|
return { settled: false };
|
|
3978
4101
|
}
|
|
3979
4102
|
/**
|
|
3980
4103
|
* The mint proved this swap never happened. Marked for good first (a reload repeats only this), then its reserved
|
|
3981
4104
|
* inputs are released. Nothing sends the saved swap again: approve claims pending reviews only, and reconcile asks.
|
|
3982
4105
|
*/
|
|
4106
|
+
/**
|
|
4107
|
+
* The ecash this payment was to spend is spent, and not by this payment. Marked for good first, then the wallet lets
|
|
4108
|
+
* go of what the mint reads spent, frees the rest, and asks the mint about its other ecash there.
|
|
4109
|
+
*/
|
|
4110
|
+
async spentElsewhere(prepared, persist) {
|
|
4111
|
+
prepared.spentElsewhere = true;
|
|
4112
|
+
await persist();
|
|
4113
|
+
await this.wallet.dropReviewedCashu(prepared);
|
|
4114
|
+
return {
|
|
4115
|
+
settled: false,
|
|
4116
|
+
failed: true,
|
|
4117
|
+
error: engineText("reviewedEcashSpent")
|
|
4118
|
+
};
|
|
4119
|
+
}
|
|
3983
4120
|
async abandon(prepared, persist) {
|
|
3984
4121
|
prepared.abandoned = true;
|
|
3985
4122
|
await persist();
|
|
@@ -4146,7 +4283,15 @@ var BitcoinService = class {
|
|
|
4146
4283
|
failed: true,
|
|
4147
4284
|
error: "Another transaction spent these coins: this payment can never confirm"
|
|
4148
4285
|
};
|
|
4149
|
-
if (status.state === "missing")
|
|
4286
|
+
if (status.state === "missing") {
|
|
4287
|
+
if (!review.submittedAt) return {
|
|
4288
|
+
txid: prepared.txid,
|
|
4289
|
+
settled: false,
|
|
4290
|
+
failed: true,
|
|
4291
|
+
error: "This payment was never approved: nothing was sent"
|
|
4292
|
+
};
|
|
4293
|
+
await provider.broadcast(prepared).catch(() => {});
|
|
4294
|
+
}
|
|
4150
4295
|
return {
|
|
4151
4296
|
txid: prepared.txid,
|
|
4152
4297
|
settled: false,
|
|
@@ -11407,6 +11552,7 @@ function composeDetails(message, around) {
|
|
|
11407
11552
|
linkId: message.linkId,
|
|
11408
11553
|
sender: message.sender,
|
|
11409
11554
|
timestamp: message.timestamp,
|
|
11555
|
+
...message.sentAt !== void 0 && { sentAt: message.sentAt },
|
|
11410
11556
|
via: message.via,
|
|
11411
11557
|
delivery: message.delivery,
|
|
11412
11558
|
deliveryError: message.deliveryError,
|
|
@@ -11530,6 +11676,7 @@ var FileDesk = class {
|
|
|
11530
11676
|
if (chat) return chat;
|
|
11531
11677
|
const made = {
|
|
11532
11678
|
local: /* @__PURE__ */ new Map(),
|
|
11679
|
+
places: /* @__PURE__ */ new Map(),
|
|
11533
11680
|
saving: Promise.resolve(),
|
|
11534
11681
|
lastSaved: /* @__PURE__ */ new Map(),
|
|
11535
11682
|
files: void 0
|
|
@@ -11569,7 +11716,13 @@ var FileDesk = class {
|
|
|
11569
11716
|
this.deps.changed();
|
|
11570
11717
|
}
|
|
11571
11718
|
handle(linkId, frame) {
|
|
11572
|
-
|
|
11719
|
+
const chat = this.chat(linkId);
|
|
11720
|
+
const id = frame.t === "pf-offer" && typeof frame.id === "string" ? frame.id : void 0;
|
|
11721
|
+
if (id === void 0 || !this.deps.place || chat.local.has(id) || chat.places.has(id)) return chat.files.handle(frame);
|
|
11722
|
+
chat.places.set(id, this.deps.place(linkId));
|
|
11723
|
+
return chat.files.handle(frame).finally(() => {
|
|
11724
|
+
if (!chat.local.has(id)) chat.places.delete(id);
|
|
11725
|
+
});
|
|
11573
11726
|
}
|
|
11574
11727
|
/** Whether files/3 is live in this chat, and what the contact said it can take. */
|
|
11575
11728
|
status(linkId) {
|
|
@@ -11579,6 +11732,11 @@ var FileDesk = class {
|
|
|
11579
11732
|
peerRoom: files?.peerRoom ?? null
|
|
11580
11733
|
};
|
|
11581
11734
|
}
|
|
11735
|
+
/** A files/3 transfer of this chat's under way on its session: offered, moving, or being checked. */
|
|
11736
|
+
moving(linkId) {
|
|
11737
|
+
const files = this.chats.get(linkId)?.files;
|
|
11738
|
+
return !!files?.live && files.records().some((r) => r.state === "offered" || r.state === "active" || r.state === "verifying");
|
|
11739
|
+
}
|
|
11582
11740
|
/**
|
|
11583
11741
|
* Offers a file whose record and bytes are stored under its local id (`outgoingFileId`). Offered before (Retry, a
|
|
11584
11742
|
* restart): an unfinished transfer is taken over and offered again, a failed one sent again; never a second one.
|
|
@@ -11792,6 +11950,8 @@ var FileDesk = class {
|
|
|
11792
11950
|
...file.video && { video: file.video },
|
|
11793
11951
|
...file.image && { image: file.image }
|
|
11794
11952
|
};
|
|
11953
|
+
const place = chat.places.get(record.id), sentAt = claimedTime(file.timestamp);
|
|
11954
|
+
chat.places.delete(record.id);
|
|
11795
11955
|
chat.saving = chat.saving.then(async () => {
|
|
11796
11956
|
await fileStore.put({
|
|
11797
11957
|
id,
|
|
@@ -11816,9 +11976,12 @@ var FileDesk = class {
|
|
|
11816
11976
|
id: `peer_${record.id}`,
|
|
11817
11977
|
text: fileMessageText(message),
|
|
11818
11978
|
sender: "peer",
|
|
11819
|
-
timestamp: file.timestamp,
|
|
11820
11979
|
via: "datalink",
|
|
11821
11980
|
file: message,
|
|
11981
|
+
...place !== void 0 && sentAt !== void 0 ? {
|
|
11982
|
+
timestamp: place,
|
|
11983
|
+
sentAt
|
|
11984
|
+
} : { timestamp: file.timestamp },
|
|
11822
11985
|
...file.reply && { replyTo: receivedPairedReply(file.reply) },
|
|
11823
11986
|
...file.forwarded && { forwarded: file.forwarded },
|
|
11824
11987
|
details: { wire: fileWire("files/3", file.size) }
|
|
@@ -12234,7 +12397,7 @@ function takesPeerEdit(message, text) {
|
|
|
12234
12397
|
function withEdit(message, edit) {
|
|
12235
12398
|
const before = message.edit?.history ?? [];
|
|
12236
12399
|
const history = edit.text === message.text || edit.card && edit.card.kind !== "buttons" ? before : trimHistory([...before, {
|
|
12237
|
-
at: message.edit?.at ?? message
|
|
12400
|
+
at: message.edit?.at ?? shownTime(message),
|
|
12238
12401
|
text: message.text
|
|
12239
12402
|
}]);
|
|
12240
12403
|
const next = {
|
|
@@ -12281,9 +12444,32 @@ var RESEND_POLICY = {
|
|
|
12281
12444
|
/** Queued this long with the contact away, it is handed to store-and-forward when the chat allows it. */
|
|
12282
12445
|
holdAfterMs: 6e4
|
|
12283
12446
|
};
|
|
12447
|
+
/** How long one turn of an outbox may hold the next one back: a send that never returns must not stop the chat. */
|
|
12448
|
+
var TURN_MS = 3e4;
|
|
12449
|
+
/**
|
|
12450
|
+
* A text of mine, not handed to store-and-forward, and still to go: queued, or waiting for the chat to carry it. (A
|
|
12451
|
+
* file or a payment request that waits for the live session has no wire id, and its own sender.)
|
|
12452
|
+
*/
|
|
12453
|
+
var toGo = (m) => m.sender === "me" && m.via !== "hold" && !!m.wireId && (m.delivery === "queued" || m.delivery === "waiting");
|
|
12454
|
+
/** A file of mine that waits for the live link (its row has no wire id: the transfer has its own). */
|
|
12455
|
+
var waitingFile = (m) => m.sender === "me" && m.via !== "hold" && !m.wireId && !!m.file && m.delivery === "waiting";
|
|
12456
|
+
var oldestFirst = (a, b) => a.timestamp - b.timestamp;
|
|
12284
12457
|
/** Durable message IDs survive retries. A lost receipt means unknown delivery, never a claim that the peer did
|
|
12285
12458
|
* not receive the message: the receiver acknowledges a duplicate id without showing it twice, so an unconfirmed
|
|
12286
|
-
* message is sent again by itself while the chat can carry it, within `RESEND_POLICY`, then waits for Retry.
|
|
12459
|
+
* message is sent again by itself while the chat can carry it, within `RESEND_POLICY`, then waits for Retry.
|
|
12460
|
+
*
|
|
12461
|
+
* What a chat's outbox hands its link goes oldest first. The contact's app places each message where it arrives
|
|
12462
|
+
* (WISP 400, requirement 10), so the order they are sent in is the order the contact reads them in, for good. A new
|
|
12463
|
+
* message, a link that came back, a receipt that freed the DHT path and each queued message's own timer all send, and
|
|
12464
|
+
* each of them took its turn while another was still between two steps: a message written later went out before one
|
|
12465
|
+
* written earlier, and the contact kept them that way. They now take turns (`turn`), and each turn sends what is
|
|
12466
|
+
* older first. Only a message the chat cannot carry (a long text while on the DHT) is passed by a later one.
|
|
12467
|
+
*
|
|
12468
|
+
* A link that closes takes its turn too (`disconnected`), and so does a receipt that never came. Both make a sent
|
|
12469
|
+
* message queued again, and both did it beside a look in progress: a link that dropped with a hundred texts
|
|
12470
|
+
* unconfirmed and came back within a second was looked at while half of them were between sent and queued, the look
|
|
12471
|
+
* passed those by and sent the later ones first. A look in progress when the link closes stops there: what it sent
|
|
12472
|
+
* on the old link may be lost, and the ones after them must not go on the new link before them. */
|
|
12287
12473
|
var Outbox = class {
|
|
12288
12474
|
store;
|
|
12289
12475
|
send;
|
|
@@ -12295,8 +12481,18 @@ var Outbox = class {
|
|
|
12295
12481
|
waiting = /* @__PURE__ */ new Map();
|
|
12296
12482
|
attempts = /* @__PURE__ */ new Map();
|
|
12297
12483
|
busy = /* @__PURE__ */ new Set();
|
|
12298
|
-
/**
|
|
12484
|
+
/** Still in flight when the look a reopened chat asked for began (`flush({ reopened })`): sent again once that send returns. */
|
|
12299
12485
|
reopenedWhileBusy = /* @__PURE__ */ new Set();
|
|
12486
|
+
/** The turns taken so far, one after the other (`turn`). */
|
|
12487
|
+
line = Promise.resolve();
|
|
12488
|
+
/** How many times the chat reopened, and the count each message last went out under: one sent since is on the new link. */
|
|
12489
|
+
opened = 0;
|
|
12490
|
+
sentUnder = /* @__PURE__ */ new Map();
|
|
12491
|
+
/** How many times the link closed with something unconfirmed on it, and the count when the turn in progress began. */
|
|
12492
|
+
closed = 0;
|
|
12493
|
+
began = 0;
|
|
12494
|
+
/** The link closed and what was unconfirmed on it is not queued again yet: the next turn does it first. */
|
|
12495
|
+
dropped = false;
|
|
12300
12496
|
stopped = false;
|
|
12301
12497
|
resender;
|
|
12302
12498
|
policy;
|
|
@@ -12333,13 +12529,137 @@ var Outbox = class {
|
|
|
12333
12529
|
else await this.queue(message, "Not confirmed before the app closed. It is sent again, under the same ID, once your contact is reachable.");
|
|
12334
12530
|
}
|
|
12335
12531
|
}
|
|
12336
|
-
/**
|
|
12337
|
-
|
|
12532
|
+
/**
|
|
12533
|
+
* One at a time, in the order asked: `run` starts when the turns before it are over. A turn that takes longer than
|
|
12534
|
+
* `TURN_MS` no longer holds the next one back.
|
|
12535
|
+
*/
|
|
12536
|
+
turn(run) {
|
|
12537
|
+
const result = this.line.then(async () => {
|
|
12538
|
+
if (this.dropped) {
|
|
12539
|
+
this.dropped = false;
|
|
12540
|
+
await this.requeueUnconfirmed();
|
|
12541
|
+
}
|
|
12542
|
+
this.began = this.closed;
|
|
12543
|
+
return run();
|
|
12544
|
+
});
|
|
12545
|
+
this.line = new Promise((next) => {
|
|
12546
|
+
const timer = setTimeout(next, TURN_MS);
|
|
12547
|
+
result.then(() => {}, () => {}).then(() => {
|
|
12548
|
+
clearTimeout(timer);
|
|
12549
|
+
next();
|
|
12550
|
+
});
|
|
12551
|
+
});
|
|
12552
|
+
return result;
|
|
12553
|
+
}
|
|
12554
|
+
/** The link has not closed since this turn began: what the turn sends goes on the link the turn looked at. */
|
|
12555
|
+
get sameLink() {
|
|
12556
|
+
return !this.stopped && this.closed === this.began;
|
|
12557
|
+
}
|
|
12558
|
+
/** A new message a turn could not send (the link closed under it, or the ones before it still wait): it waits too. */
|
|
12559
|
+
async waitsItsTurn(message) {
|
|
12560
|
+
await this.store.update(message.id, "waiting", "Waits for the messages before it to go.", {
|
|
12561
|
+
via: "datalink",
|
|
12562
|
+
resendUntil: message.resendUntil ?? this.now() + this.policy.windowMs
|
|
12563
|
+
});
|
|
12564
|
+
this.schedule(message.id);
|
|
12565
|
+
}
|
|
12566
|
+
/**
|
|
12567
|
+
* Sends one message. A new one goes after the ones written before it that still wait to go: those the chat can
|
|
12568
|
+
* carry go first, and if it then cannot carry this one (the DHT path took one of them), it waits its turn too.
|
|
12569
|
+
* `manual`: the Retry button — it goes now, with a new window and a fresh count of attempts.
|
|
12570
|
+
*/
|
|
12571
|
+
transmit(id, { manual = false } = {}) {
|
|
12572
|
+
return this.turn(async () => {
|
|
12573
|
+
if (manual || this.stopped || this.busy.has(id) || this.receipts.has(id)) return this.transmitNow(id, { manual });
|
|
12574
|
+
const messages = await this.store.read(), message = messages.find((m) => m.id === id);
|
|
12575
|
+
const before = message?.delivery === "sending" ? this.before(messages, message) : [];
|
|
12576
|
+
if (!before.length) return this.transmitNow(id, {}, message);
|
|
12577
|
+
const passed = await this.inOrder(before);
|
|
12578
|
+
if (this.stopped) return;
|
|
12579
|
+
if (this.sameLink && this.resender.ready({
|
|
12580
|
+
...message,
|
|
12581
|
+
via: "datalink"
|
|
12582
|
+
})) {
|
|
12583
|
+
await this.catchUp(passed);
|
|
12584
|
+
if (this.sameLink) return this.transmitNow(id);
|
|
12585
|
+
}
|
|
12586
|
+
if (this.stopped) return;
|
|
12587
|
+
await this.waitsItsTurn(message);
|
|
12588
|
+
});
|
|
12589
|
+
}
|
|
12590
|
+
/** The messages written before a new one that have not gone yet, oldest first. */
|
|
12591
|
+
before(messages, message) {
|
|
12592
|
+
const stored = (m) => m.sender === "me" && m.via !== "hold" && m.delivery === "sending" && !!m.wireId && m.timestamp < message.timestamp && !this.receipts.has(m.id) && !this.busy.has(m.id);
|
|
12593
|
+
return [...this.due(messages.filter((m) => m.id !== message.id && m.timestamp <= message.timestamp)), ...messages.filter(stored)].sort(oldestFirst);
|
|
12594
|
+
}
|
|
12595
|
+
/**
|
|
12596
|
+
* What a look at the chat takes up, oldest first: what is queued or waiting, and what was sent and still awaits its
|
|
12597
|
+
* receipt when the path it took is not the one the chat has now (it went on the DHT and the chat is live: it goes on
|
|
12598
|
+
* the link, before what was written after it) or when the chat reopened since (`opened`).
|
|
12599
|
+
*/
|
|
12600
|
+
due(messages, opened) {
|
|
12601
|
+
const unconfirmed = (m) => m.sender === "me" && m.via !== "hold" && m.delivery === "sent" && this.receipts.has(m.id);
|
|
12602
|
+
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);
|
|
12603
|
+
}
|
|
12604
|
+
/**
|
|
12605
|
+
* Looks at each message, oldest first: it goes if the chat can carry it. The ones the chat could not carry are
|
|
12606
|
+
* handed back, and asked once more at the moment a later one is found able to go (`catchUp`): a link says it is open
|
|
12607
|
+
* a moment before it carries text, and a look that began in that moment sent the later messages and left the first
|
|
12608
|
+
* ones behind.
|
|
12609
|
+
*/
|
|
12610
|
+
/** Whether this chat's waiting files go through this outbox (`Resender.sendFile`). */
|
|
12611
|
+
get sendsFiles() {
|
|
12612
|
+
return !!this.resender.sendFile;
|
|
12613
|
+
}
|
|
12614
|
+
file(message) {
|
|
12615
|
+
return this.sendsFiles && waitingFile(message);
|
|
12616
|
+
}
|
|
12617
|
+
/** A waiting file is said on the link, after the older messages passed by that can go now. False: the link cannot carry it. */
|
|
12618
|
+
async fileGoes(message, passed) {
|
|
12619
|
+
if (!this.resender.ready(message)) {
|
|
12620
|
+
if (!this.waiting.get(message.id)?.timer) this.schedule(message.id);
|
|
12621
|
+
return false;
|
|
12622
|
+
}
|
|
12623
|
+
if (passed) await this.catchUp(passed);
|
|
12624
|
+
if (!this.sameLink) return true;
|
|
12625
|
+
await this.resender.sendFile(message).catch(() => {});
|
|
12626
|
+
this.forget(message.id);
|
|
12627
|
+
return true;
|
|
12628
|
+
}
|
|
12629
|
+
async inOrder(messages) {
|
|
12630
|
+
const passed = [];
|
|
12631
|
+
for (const message of messages) {
|
|
12632
|
+
if (!this.sameLink) break;
|
|
12633
|
+
if (message.delivery === "sending") {
|
|
12634
|
+
await this.catchUp(passed);
|
|
12635
|
+
if (this.sameLink) await this.transmitNow(message.id);
|
|
12636
|
+
else if (!this.stopped) await this.waitsItsTurn(message);
|
|
12637
|
+
continue;
|
|
12638
|
+
}
|
|
12639
|
+
if (!await (this.file(message) ? this.fileGoes(message, passed) : this.again(message, passed))) passed.push(message);
|
|
12640
|
+
}
|
|
12641
|
+
return passed;
|
|
12642
|
+
}
|
|
12643
|
+
/** The messages passed by that the chat can carry by now go, oldest first; the others stay where they are. */
|
|
12644
|
+
async catchUp(passed) {
|
|
12645
|
+
for (let i = 0; i < passed.length && this.sameLink;) {
|
|
12646
|
+
if (!this.resender.ready(passed[i])) {
|
|
12647
|
+
i++;
|
|
12648
|
+
continue;
|
|
12649
|
+
}
|
|
12650
|
+
const [older] = passed.splice(i, 1);
|
|
12651
|
+
if (this.file(older)) await this.fileGoes(older);
|
|
12652
|
+
else await this.attempt(older.id);
|
|
12653
|
+
i = 0;
|
|
12654
|
+
}
|
|
12655
|
+
}
|
|
12656
|
+
/** `read`: the message's row, when it was read just now with nothing awaited since. */
|
|
12657
|
+
async transmitNow(id, { manual = false } = {}, read) {
|
|
12338
12658
|
if (this.stopped || this.busy.has(id) || this.receipts.has(id)) return;
|
|
12339
12659
|
this.busy.add(id);
|
|
12340
12660
|
let again;
|
|
12341
12661
|
try {
|
|
12342
|
-
let message = (await this.store.read()).find((m) => m.id === id);
|
|
12662
|
+
let message = read ?? (await this.store.read()).find((m) => m.id === id);
|
|
12343
12663
|
if (!message?.wireId || !message.delivery || message.delivery === "delivered") return;
|
|
12344
12664
|
const wait = this.waiting.get(id);
|
|
12345
12665
|
if (wait?.timer) {
|
|
@@ -12366,6 +12686,7 @@ var Outbox = class {
|
|
|
12366
12686
|
return;
|
|
12367
12687
|
}
|
|
12368
12688
|
this.track(id, typeof this.timeoutMs === "number" ? this.timeoutMs : this.timeoutMs(message));
|
|
12689
|
+
this.sentUnder.set(id, this.opened);
|
|
12369
12690
|
const error = await this.send(message);
|
|
12370
12691
|
if (error) {
|
|
12371
12692
|
this.clear(id);
|
|
@@ -12406,25 +12727,53 @@ var Outbox = class {
|
|
|
12406
12727
|
* oldest first. `reopened`: a new link, so what still awaits a receipt from the old one, or from the DHT, goes on it too
|
|
12407
12728
|
* (a receipt on the link also ends the DHT's own retries).
|
|
12408
12729
|
*/
|
|
12409
|
-
|
|
12410
|
-
if (this.stopped || !reopened && !this.waiting.size) return;
|
|
12411
|
-
|
|
12412
|
-
|
|
12413
|
-
|
|
12414
|
-
|
|
12415
|
-
|
|
12416
|
-
|
|
12417
|
-
|
|
12418
|
-
|
|
12419
|
-
|
|
12730
|
+
flush({ reopened = false } = {}) {
|
|
12731
|
+
if (this.stopped || !reopened && !this.waiting.size) return Promise.resolve();
|
|
12732
|
+
const opened = reopened ? ++this.opened : void 0;
|
|
12733
|
+
return this.turn(async () => {
|
|
12734
|
+
if (this.stopped) return;
|
|
12735
|
+
if (reopened) for (const id of this.busy) this.reopenedWhileBusy.add(id);
|
|
12736
|
+
await this.inOrder(this.due(await this.store.read(), opened));
|
|
12737
|
+
});
|
|
12738
|
+
}
|
|
12739
|
+
/** A queued message's check came due: the ones written before it that have not gone are looked at first. */
|
|
12740
|
+
async check(id) {
|
|
12741
|
+
if (this.stopped) return;
|
|
12742
|
+
const messages = await this.store.read(), message = messages.find((m) => m.id === id);
|
|
12743
|
+
if (!message || !(toGo(message) || this.file(message))) {
|
|
12744
|
+
await this.attempt(id);
|
|
12420
12745
|
return;
|
|
12421
12746
|
}
|
|
12422
|
-
|
|
12747
|
+
await this.inOrder(this.due(messages.filter((m) => m.timestamp <= message.timestamp)));
|
|
12748
|
+
}
|
|
12749
|
+
/**
|
|
12750
|
+
* A queued or waiting message is looked at; one sent and awaiting its receipt goes again now, if the chat can carry
|
|
12751
|
+
* it. False when the chat could not carry it.
|
|
12752
|
+
*/
|
|
12753
|
+
async again(message, passed) {
|
|
12754
|
+
if (message.sender !== "me" || message.via === "hold") return true;
|
|
12755
|
+
if (message.delivery === "queued" || message.delivery === "waiting") return this.attempt(message.id, passed);
|
|
12756
|
+
if (message.delivery !== "sent" || !this.receipts.has(message.id)) return true;
|
|
12423
12757
|
this.clear(message.id);
|
|
12424
|
-
if (this.resender.ready(message))
|
|
12425
|
-
|
|
12758
|
+
if (this.resender.ready(message)) {
|
|
12759
|
+
if (passed) await this.catchUp(passed);
|
|
12760
|
+
await this.transmitNow(message.id);
|
|
12761
|
+
return true;
|
|
12762
|
+
}
|
|
12763
|
+
await this.requeue(message.id, "Connection closed before receipt. Delivery is unconfirmed.");
|
|
12764
|
+
return false;
|
|
12765
|
+
}
|
|
12766
|
+
/**
|
|
12767
|
+
* The link closed: what awaits a receipt from it is queued again, in a turn of its own, so a look at the chat never
|
|
12768
|
+
* finds a message between sent and queued. A look in progress stops (`sameLink`).
|
|
12769
|
+
*/
|
|
12770
|
+
disconnected() {
|
|
12771
|
+
if (!this.receipts.size) return Promise.resolve();
|
|
12772
|
+
this.closed++;
|
|
12773
|
+
this.dropped = true;
|
|
12774
|
+
return this.turn(async () => {});
|
|
12426
12775
|
}
|
|
12427
|
-
async
|
|
12776
|
+
async requeueUnconfirmed() {
|
|
12428
12777
|
const messages = await this.store.read();
|
|
12429
12778
|
for (const id of [...this.receipts.keys()]) {
|
|
12430
12779
|
if (messages.find((m) => m.id === id)?.via === "pkarr") {
|
|
@@ -12440,33 +12789,40 @@ var Outbox = class {
|
|
|
12440
12789
|
this.reopenedWhileBusy.clear();
|
|
12441
12790
|
for (const wait of this.waiting.values()) if (wait.timer) clearTimeout(wait.timer);
|
|
12442
12791
|
this.waiting.clear();
|
|
12443
|
-
|
|
12792
|
+
this.dropped = false;
|
|
12793
|
+
await this.requeueUnconfirmed();
|
|
12444
12794
|
}
|
|
12445
|
-
/**
|
|
12446
|
-
|
|
12447
|
-
|
|
12795
|
+
/**
|
|
12796
|
+
* Looks at one queued message: sends it if the chat can carry it, hands it to store-and-forward, or waits more.
|
|
12797
|
+
* False when the chat could not carry it and it still waits. `passed`: older messages the chat could not carry a
|
|
12798
|
+
* moment ago, which go first if it can now.
|
|
12799
|
+
*/
|
|
12800
|
+
async attempt(id, passed) {
|
|
12801
|
+
if (this.stopped || this.busy.has(id)) return true;
|
|
12448
12802
|
const message = (await this.store.read()).find((m) => m.id === id);
|
|
12449
12803
|
if (!message || message.delivery !== "queued" && message.delivery !== "waiting" || message.via === "hold") {
|
|
12450
12804
|
this.forget(id);
|
|
12451
|
-
return;
|
|
12805
|
+
return true;
|
|
12452
12806
|
}
|
|
12453
12807
|
if ((message.resendUntil ?? Infinity) <= this.now()) {
|
|
12454
12808
|
if (message.delivery === "waiting") {
|
|
12455
12809
|
this.forget(id);
|
|
12456
12810
|
await this.store.update(id, "failed", `Your contact was not reachable within ${days(this.policy.windowMs)}. It was not sent; Retry sends it.`);
|
|
12457
12811
|
} else await this.fail(id, `Not confirmed within ${days(this.policy.windowMs)}.`);
|
|
12458
|
-
return;
|
|
12812
|
+
return true;
|
|
12459
12813
|
}
|
|
12460
12814
|
if (this.resender.ready(message)) {
|
|
12461
|
-
await this.
|
|
12462
|
-
|
|
12815
|
+
if (passed) await this.catchUp(passed);
|
|
12816
|
+
await this.transmitNow(id);
|
|
12817
|
+
return true;
|
|
12463
12818
|
}
|
|
12464
12819
|
const wait = this.waiting.get(id);
|
|
12465
12820
|
if (this.resender.divert && wait && this.now() - wait.since >= this.policy.holdAfterMs && await this.resender.divert(message)) {
|
|
12466
12821
|
this.forget(id);
|
|
12467
|
-
return;
|
|
12822
|
+
return true;
|
|
12468
12823
|
}
|
|
12469
12824
|
if (!wait?.timer) this.schedule(id);
|
|
12825
|
+
return false;
|
|
12470
12826
|
}
|
|
12471
12827
|
/** After a failed attempt: queued again, unless its window or its attempts are spent. */
|
|
12472
12828
|
async requeue(id, reason) {
|
|
@@ -12501,7 +12857,7 @@ var Outbox = class {
|
|
|
12501
12857
|
wait.timer = setTimeout(() => {
|
|
12502
12858
|
wait.timer = void 0;
|
|
12503
12859
|
wait.step++;
|
|
12504
|
-
this.
|
|
12860
|
+
this.turn(() => this.check(id)).catch(() => {});
|
|
12505
12861
|
}, delay);
|
|
12506
12862
|
this.waiting.set(id, wait);
|
|
12507
12863
|
}
|
|
@@ -12510,17 +12866,20 @@ var Outbox = class {
|
|
|
12510
12866
|
if (wait?.timer) clearTimeout(wait.timer);
|
|
12511
12867
|
this.waiting.delete(id);
|
|
12512
12868
|
this.attempts.delete(id);
|
|
12869
|
+
this.sentUnder.delete(id);
|
|
12513
12870
|
}
|
|
12514
12871
|
track(id, duration) {
|
|
12515
12872
|
this.clear(id);
|
|
12516
|
-
|
|
12517
|
-
this.
|
|
12518
|
-
|
|
12873
|
+
const timer = setTimeout(() => {
|
|
12874
|
+
this.turn(async () => {
|
|
12875
|
+
if (this.receipts.get(id) !== timer) return;
|
|
12876
|
+
this.receipts.delete(id);
|
|
12519
12877
|
const message = (await this.store.read()).find((m) => m.id === id);
|
|
12520
12878
|
if (message?.via === "pkarr") await this.expired(message);
|
|
12521
12879
|
else await this.requeue(id, "No receipt received yet.");
|
|
12522
|
-
})
|
|
12523
|
-
}, duration)
|
|
12880
|
+
}).catch(() => {});
|
|
12881
|
+
}, duration);
|
|
12882
|
+
this.receipts.set(id, timer);
|
|
12524
12883
|
}
|
|
12525
12884
|
clear(id) {
|
|
12526
12885
|
const timer = this.receipts.get(id);
|
|
@@ -12758,7 +13117,8 @@ var ITEM = {
|
|
|
12758
13117
|
invoice: (a) => `An invoice for ${a}, not paid yet`,
|
|
12759
13118
|
paid: (a) => `${a} paid to an invoice, not claimed from the mint yet`,
|
|
12760
13119
|
unclaimed: (a) => `${a} the mint says it issued for an invoice, never received here`,
|
|
12761
|
-
sent: (a) => `${a} in ecash you sent, not taken yet
|
|
13120
|
+
sent: (a) => `${a} in ecash you sent, not taken yet`,
|
|
13121
|
+
swap: (a) => `${a} in an exchange with the mint, not finished yet`
|
|
12762
13122
|
};
|
|
12763
13123
|
var ENGLISH_REMOVAL = {
|
|
12764
13124
|
sats: (n, network) => `${n.toLocaleString("en-US")} ${network === "testnet" ? "test sats" : "sats"}`,
|
|
@@ -12783,8 +13143,13 @@ function items(type, network, view, words, card) {
|
|
|
12783
13143
|
...a.paymentId ? { paymentId: a.paymentId } : {}
|
|
12784
13144
|
});
|
|
12785
13145
|
const returns = (a) => a.kind === "sent" && type === "cashu";
|
|
13146
|
+
const swaps = type === "cashu" && view?.openSwaps ? [{
|
|
13147
|
+
kind: "swap",
|
|
13148
|
+
text: words.item("swap", amount(view.swapsAmount ?? 0)),
|
|
13149
|
+
amount: amount(view.swapsAmount ?? 0)
|
|
13150
|
+
}] : [];
|
|
12786
13151
|
return {
|
|
12787
|
-
awaiting: mine.filter((a) => !returns(a)).map(item),
|
|
13152
|
+
awaiting: [...mine.filter((a) => !returns(a)).map(item), ...swaps],
|
|
12788
13153
|
returnable: mine.filter(returns).map(item)
|
|
12789
13154
|
};
|
|
12790
13155
|
}
|
|
@@ -12806,7 +13171,7 @@ function walletRemoval(type, network, view, intents = [], card, words = ENGLISH_
|
|
|
12806
13171
|
case "cashu": return {
|
|
12807
13172
|
...base,
|
|
12808
13173
|
custody: "device",
|
|
12809
|
-
held: known(view?.balance ?? 0),
|
|
13174
|
+
held: known((view?.balance ?? 0) + (view?.setAside ?? 0)),
|
|
12810
13175
|
backup: "tokens",
|
|
12811
13176
|
pending
|
|
12812
13177
|
};
|
|
@@ -13281,6 +13646,16 @@ var db = {
|
|
|
13281
13646
|
want += newest.length - page.length;
|
|
13282
13647
|
}
|
|
13283
13648
|
},
|
|
13649
|
+
/**
|
|
13650
|
+
* Every message of this profile that carries a task or a routine card, from every chat and group, oldest first (time,
|
|
13651
|
+
* then id). Reads the card index only (`CARD_INDEX`): no chat's history is read, however long. The index follows the
|
|
13652
|
+
* rows, so an edit's card, a deleted message and a deleted chat or group are in step with no bookkeeping.
|
|
13653
|
+
*/
|
|
13654
|
+
async getCardMessages() {
|
|
13655
|
+
const index = (await store(STORES.messages, "readonly")).index(CARD_INDEX);
|
|
13656
|
+
const [tasks, routines] = await Promise.all([wrap(index.getAll("task")), wrap(index.getAll("routine"))]);
|
|
13657
|
+
return [...tasks, ...routines].sort((a, b) => a.timestamp - b.timestamp || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
13658
|
+
},
|
|
13284
13659
|
/** Returns false when the message was already stored. */
|
|
13285
13660
|
async addMessage(message) {
|
|
13286
13661
|
const tx = (await openDb()).transaction(STORES.messages, "readwrite");
|
|
@@ -13381,6 +13756,32 @@ var db = {
|
|
|
13381
13756
|
}
|
|
13382
13757
|
};
|
|
13383
13758
|
//#endregion
|
|
13759
|
+
//#region ../browser/src/engine/arrival.ts
|
|
13760
|
+
/**
|
|
13761
|
+
* A history stored before a received row was placed where it arrived (WISP 400, requirement 10; until 2026-10): a
|
|
13762
|
+
* contact's row sits under the time the contact's clock said, up to five minutes ahead of this device's. Almost all of
|
|
13763
|
+
* it stays as it is: its stored time is its place, so nothing moves when the app updates. Only the rows whose stored
|
|
13764
|
+
* time has not come yet are settled, once, at the start: they are the last rows of their chat, and left there a
|
|
13765
|
+
* message written in the next minutes would sort above them. Each takes a place just before now, in the order they
|
|
13766
|
+
* were in and after everything before them, and keeps the time its sender said beside it (`sentAt`).
|
|
13767
|
+
*
|
|
13768
|
+
* Returns the rows to write, as they are now; none when nothing is ahead. Rows of mine are never touched: their time
|
|
13769
|
+
* is the one both sides know them by.
|
|
13770
|
+
*/
|
|
13771
|
+
function settleAhead(history, now) {
|
|
13772
|
+
const ahead = history.filter((m) => m.sender === "peer" && m.sentAt === void 0 && m.timestamp > now);
|
|
13773
|
+
if (!ahead.length) return [];
|
|
13774
|
+
let last = history.reduce((max, m) => m.timestamp > now ? max : Math.max(max, m.timestamp), 0);
|
|
13775
|
+
return ahead.map((m, i) => {
|
|
13776
|
+
last = Math.max(last + 1, now - (ahead.length - i));
|
|
13777
|
+
return {
|
|
13778
|
+
...m,
|
|
13779
|
+
timestamp: last,
|
|
13780
|
+
sentAt: m.timestamp
|
|
13781
|
+
};
|
|
13782
|
+
});
|
|
13783
|
+
}
|
|
13784
|
+
//#endregion
|
|
13384
13785
|
//#region ../browser/src/engine/reactions.ts
|
|
13385
13786
|
var isGroup$1 = (chat) => chat.startsWith("group:");
|
|
13386
13787
|
/** The row a reaction names in a chat: the id both sides know it by (`replyRef`). */
|
|
@@ -13721,6 +14122,36 @@ var HUB_GRACE_MS = 18e4;
|
|
|
13721
14122
|
var REFUSED_FOR_MS$1 = 6e5;
|
|
13722
14123
|
var ENTRY_LINGER_MS$1 = 2e4;
|
|
13723
14124
|
var RELAYED_KEPT = 4096;
|
|
14125
|
+
/**
|
|
14126
|
+
* Someone the chain took out while its app was closed comes back believing it is a member: it asks in a hub's lobby,
|
|
14127
|
+
* or lists itself as a hub. A hub then keeps an edge to it for a moment, only to hand it the commit that took it out
|
|
14128
|
+
* (`farewell`): at most `FAREWELL_EDGES` at a time, each awaited `FAREWELL_WAIT_MS` and closed `FAREWELL_LINGER_MS`
|
|
14129
|
+
* after the commit went; the same key is not waited for again for a while (longer once it was told).
|
|
14130
|
+
*/
|
|
14131
|
+
var FAREWELL_EDGES = 4;
|
|
14132
|
+
var FAREWELL_WAIT_MS = 12e4;
|
|
14133
|
+
var FAREWELL_LINGER_MS = 1e4;
|
|
14134
|
+
var FAREWELL_RETRY_MS = 6e4;
|
|
14135
|
+
var FAREWELL_TOLD_MS = 6e5;
|
|
14136
|
+
var FAREWELL_SAY_MS = 1e4;
|
|
14137
|
+
/**
|
|
14138
|
+
* A key asking from before the commit that took it out was there and was sent that commit: not waited for. The two
|
|
14139
|
+
* times are two devices' clocks (the asker's, the commit's signer's), so "before" allows this much between them:
|
|
14140
|
+
* a returning member whose clock runs behind the admin's is told all the same.
|
|
14141
|
+
*/
|
|
14142
|
+
var FAREWELL_SKEW_MS = 3e5;
|
|
14143
|
+
/**
|
|
14144
|
+
* A hub dates its beacon entry by its own clock, and a reader judges it by its own. A hub's time closer than this to
|
|
14145
|
+
* what this app saw is taken as written; past it the two clocks differ, and the entry is read in this app's time
|
|
14146
|
+
* (`inMyTime`). Half as long again as a hub's pace: what an entry may trail a reading by, between two clocks that agree.
|
|
14147
|
+
*/
|
|
14148
|
+
var HUB_CLOCK_SLACK_MS = COMMUNITY_TOPOLOGY.beaconEveryMs * 1.5;
|
|
14149
|
+
/** Two readings of the beacon no further apart than this say when an entry that moved between them was written. */
|
|
14150
|
+
var HUB_CLOCK_READ_GAP_MS = 45e3;
|
|
14151
|
+
/** A hub's clock is remembered this long after its entry was last in the beacon. */
|
|
14152
|
+
var HUB_CLOCK_KEPT_MS = 6e5;
|
|
14153
|
+
/** A member that opens the group's link again knocks to check it is still in, this long at most (`joinByLink`). */
|
|
14154
|
+
var CHECK_MS = 18e4;
|
|
13724
14155
|
var MESSAGE_LINK$1 = (groupId) => `group:${groupId}`;
|
|
13725
14156
|
/**
|
|
13726
14157
|
* A member key for a joiner whose entry session the member's side dials. Of a paired session's two
|
|
@@ -13752,6 +14183,8 @@ var Communities = class {
|
|
|
13752
14183
|
/** Joiners: groups whose knock is out (published), and the knock record it is in. */
|
|
13753
14184
|
knocked = /* @__PURE__ */ new Set();
|
|
13754
14185
|
knockAt = /* @__PURE__ */ new Map();
|
|
14186
|
+
/** Joiners: how many others had a knock still being refreshed in the record I last read (they wait with me). */
|
|
14187
|
+
knockingWith = /* @__PURE__ */ new Map();
|
|
13755
14188
|
/** My frames an edge took, and which of them carry an edit (`frame id → editKey`). */
|
|
13756
14189
|
frames = new FramesTaken();
|
|
13757
14190
|
carriers = /* @__PURE__ */ new Map();
|
|
@@ -13787,7 +14220,8 @@ var Communities = class {
|
|
|
13787
14220
|
}
|
|
13788
14221
|
/** Is this device a hub of the group? (tests and the load harness) */
|
|
13789
14222
|
isHub(groupId) {
|
|
13790
|
-
|
|
14223
|
+
const live = this.live.get(groupId);
|
|
14224
|
+
return !!live?.hub && live.session.status === "active";
|
|
13791
14225
|
}
|
|
13792
14226
|
async load(groups) {
|
|
13793
14227
|
for (const group of groups) {
|
|
@@ -13796,9 +14230,11 @@ var Communities = class {
|
|
|
13796
14230
|
const history = await this.store.getMessages(MESSAGE_LINK$1(group.id)), said = history.filter((m) => !m.event && !m.groupPay);
|
|
13797
14231
|
const last = said[said.length - 1], lastPeer = [...said].reverse().find((m) => m.sender !== "me");
|
|
13798
14232
|
if (last) this.lastMessageAt.set(group.id, last.timestamp);
|
|
13799
|
-
|
|
14233
|
+
const peerAt = cameOrWritten(lastPeer?.timestamp, lastPeer ? group.came?.peer : void 0);
|
|
14234
|
+
if (peerAt) this.lastPeerMessageAt.set(group.id, peerAt);
|
|
13800
14235
|
const mention = [...history].reverse().find((m) => m.mentioned);
|
|
13801
|
-
|
|
14236
|
+
const mentionAt = cameOrWritten(mention?.timestamp, mention ? group.came?.mention : void 0);
|
|
14237
|
+
if (mentionAt) this.lastMentionAt.set(group.id, mentionAt);
|
|
13802
14238
|
for (const [, linkId] of this.host.entries(group.id)) if (group.joining?.linkId !== linkId) await this.host.closeEdge(linkId);
|
|
13803
14239
|
const live = this.live.get(group.id);
|
|
13804
14240
|
if (live) for (const key of this.host.edges(group.id).keys()) live.restored.set(key, 0);
|
|
@@ -13821,7 +14257,7 @@ var Communities = class {
|
|
|
13821
14257
|
invited: [],
|
|
13822
14258
|
memberLinks: {}
|
|
13823
14259
|
};
|
|
13824
|
-
if (
|
|
14260
|
+
if (this.knocking(group, live)) return [{
|
|
13825
14261
|
...base,
|
|
13826
14262
|
name: groupName(group.joining.name) ?? live?.session.name ?? "",
|
|
13827
14263
|
isAdmin: false,
|
|
@@ -13834,7 +14270,8 @@ var Communities = class {
|
|
|
13834
14270
|
members: 0,
|
|
13835
14271
|
accepted: true,
|
|
13836
14272
|
viaLink: true,
|
|
13837
|
-
stage: this.joinStage(group)
|
|
14273
|
+
stage: this.joinStage(group),
|
|
14274
|
+
...this.knockingWith.get(group.id) ? { waiting: this.knockingWith.get(group.id) } : {}
|
|
13838
14275
|
}
|
|
13839
14276
|
}];
|
|
13840
14277
|
if (!live) return [];
|
|
@@ -13843,7 +14280,7 @@ var Communities = class {
|
|
|
13843
14280
|
const id = edges.get(key);
|
|
13844
14281
|
return !!id && this.host.linkReady(id, 2);
|
|
13845
14282
|
};
|
|
13846
|
-
const hubs = freshHubs(live
|
|
14283
|
+
const hubs = freshHubs(this.hubs(live), this.now()).map((h) => h.key);
|
|
13847
14284
|
return [{
|
|
13848
14285
|
...base,
|
|
13849
14286
|
name: s.name,
|
|
@@ -13904,17 +14341,36 @@ var Communities = class {
|
|
|
13904
14341
|
if (!link) throw new Error("This is not a link to a group");
|
|
13905
14342
|
const existing = this.stored.get(link.g);
|
|
13906
14343
|
const live = this.live.get(link.g);
|
|
13907
|
-
if (live && live.session.status === "active") return link.g;
|
|
13908
14344
|
if (existing?.joining && existing.joining.host === link.host) return link.g;
|
|
14345
|
+
if (live) {
|
|
14346
|
+
const s = live.session;
|
|
14347
|
+
if (s.status === "active" && s.roster.length === 1) return link.g;
|
|
14348
|
+
if (s.status === "active") {
|
|
14349
|
+
await this.startJoining(link, s.state.seedB64, this.now(), true);
|
|
14350
|
+
return link.g;
|
|
14351
|
+
}
|
|
14352
|
+
if (s.status === "removed" || s.status === "lost") {
|
|
14353
|
+
await this.startJoining(link, s.state.seedB64, this.now());
|
|
14354
|
+
return link.g;
|
|
14355
|
+
}
|
|
14356
|
+
}
|
|
13909
14357
|
if (existing) await this.forget(link.g);
|
|
13910
14358
|
await this.startJoining(link, dialedKey(link), this.now());
|
|
13911
14359
|
return link.g;
|
|
13912
14360
|
}
|
|
13913
|
-
|
|
14361
|
+
/** Knocking to get in: a joiner, or someone who was in (removed, or its admission lost) asking again. Not a member checking it still is. */
|
|
14362
|
+
knocking(group, live) {
|
|
14363
|
+
return !!group.joining && (!live || live.session.status !== "active");
|
|
14364
|
+
}
|
|
14365
|
+
/** `check`: a member, as far as this device knows, asking the door whether it still is (`joinByLink`). */
|
|
14366
|
+
async startJoining(link, seedB64, since, check = false) {
|
|
13914
14367
|
this.lastKnock.delete(link.g);
|
|
13915
14368
|
this.knocked.delete(link.g);
|
|
13916
14369
|
this.knockAt.delete(link.g);
|
|
14370
|
+
this.knockingWith.delete(link.g);
|
|
14371
|
+
const before = this.stored.get(link.g)?.joining;
|
|
13917
14372
|
const linkId = await this.host.openEntry(link, "guest", seedB64, link.host);
|
|
14373
|
+
if (before && before.linkId !== linkId) await this.host.closeEdge(before.linkId);
|
|
13918
14374
|
const group = this.stored.get(link.g) ?? {
|
|
13919
14375
|
id: link.g,
|
|
13920
14376
|
createdAt: since
|
|
@@ -13927,7 +14383,8 @@ var Communities = class {
|
|
|
13927
14383
|
name: group.community?.name ?? "",
|
|
13928
14384
|
inviter: "",
|
|
13929
14385
|
pieces: [],
|
|
13930
|
-
since
|
|
14386
|
+
since,
|
|
14387
|
+
...check && { check: true }
|
|
13931
14388
|
};
|
|
13932
14389
|
this.stored.set(link.g, group);
|
|
13933
14390
|
await this.store.putGroup(group);
|
|
@@ -13935,9 +14392,29 @@ var Communities = class {
|
|
|
13935
14392
|
traceJoin(link.g, "join.start");
|
|
13936
14393
|
this.knock(group, since).catch(() => {});
|
|
13937
14394
|
}
|
|
14395
|
+
/** The check is over (answered, or nobody was at the door): the group stays as it is here. */
|
|
14396
|
+
async endCheck(group) {
|
|
14397
|
+
const joining = group.joining;
|
|
14398
|
+
if (!joining) return;
|
|
14399
|
+
delete group.joining;
|
|
14400
|
+
this.lastKnock.delete(group.id);
|
|
14401
|
+
this.knocked.delete(group.id);
|
|
14402
|
+
this.knockAt.delete(group.id);
|
|
14403
|
+
this.knockingWith.delete(group.id);
|
|
14404
|
+
await this.host.closeEdge(joining.linkId);
|
|
14405
|
+
await this.store.putGroup(group);
|
|
14406
|
+
this.host.emit();
|
|
14407
|
+
}
|
|
13938
14408
|
async send(groupId, text, mentions = [], reply, forwarded, card) {
|
|
13939
14409
|
const live = this.live.get(groupId);
|
|
13940
|
-
if (!live) return {
|
|
14410
|
+
if (!live) return {
|
|
14411
|
+
error: "You are not in this group yet",
|
|
14412
|
+
refused: true
|
|
14413
|
+
};
|
|
14414
|
+
if (!live.session.isMember) return {
|
|
14415
|
+
error: live.session.state.statusReason ?? "You are not in this group",
|
|
14416
|
+
refused: true
|
|
14417
|
+
};
|
|
13941
14418
|
const result = card ? await live.session.sendText(text, this.host.myNick?.(), Date.now(), mentions, reply, forwarded, card) : await live.session.sendText(text, this.host.myNick?.(), Date.now(), mentions, reply, forwarded);
|
|
13942
14419
|
return "error" in result ? { error: result.error } : {
|
|
13943
14420
|
error: null,
|
|
@@ -14050,7 +14527,7 @@ var Communities = class {
|
|
|
14050
14527
|
*/
|
|
14051
14528
|
async decline(groupId) {
|
|
14052
14529
|
const group = this.stored.get(groupId), live = this.live.get(groupId);
|
|
14053
|
-
if (!group
|
|
14530
|
+
if (!group || !this.knocking(group, live)) throw new Error("No invitation to decline");
|
|
14054
14531
|
if (live) throw new Error("You were in this group: leave it to stop trying to get back in");
|
|
14055
14532
|
await this.forget(groupId);
|
|
14056
14533
|
}
|
|
@@ -14062,6 +14539,7 @@ var Communities = class {
|
|
|
14062
14539
|
this.lastMessageAt.delete(groupId);
|
|
14063
14540
|
this.lastPeerMessageAt.delete(groupId);
|
|
14064
14541
|
this.lastMentionAt.delete(groupId);
|
|
14542
|
+
this.knockingWith.delete(groupId);
|
|
14065
14543
|
for (const linkId of [...this.host.edges(groupId).values(), ...this.host.entries(groupId).values()]) await this.host.closeEdge(linkId);
|
|
14066
14544
|
await this.store.deleteGroup(groupId);
|
|
14067
14545
|
this.host.historyGone?.(groupId);
|
|
@@ -14077,15 +14555,17 @@ var Communities = class {
|
|
|
14077
14555
|
this.time = now;
|
|
14078
14556
|
for (const group of [...this.stored.values()]) {
|
|
14079
14557
|
const live = this.live.get(group.id);
|
|
14080
|
-
if (group.joining && (
|
|
14081
|
-
const
|
|
14082
|
-
if (!this.
|
|
14558
|
+
if (group.joining && (this.knocking(group, live) || group.joining.check)) {
|
|
14559
|
+
const waited = now - group.joining.since, every = waited > this.timings.patienceMs ? this.timings.slowKnockMs : this.timings.knockMs;
|
|
14560
|
+
if (!this.knocking(group, live) && waited > CHECK_MS) await this.endCheck(group);
|
|
14561
|
+
else if (!this.host.linkReady(group.joining.linkId, 2) && !this.host.linkSeen?.(group.joining.linkId) && now - (this.lastKnock.get(group.id) ?? 0) >= every) await this.knock(group, now).catch(() => {});
|
|
14083
14562
|
if (!live) continue;
|
|
14084
14563
|
}
|
|
14085
14564
|
if (!live) continue;
|
|
14086
14565
|
if (live.session.status === "lost" && !group.joining) {
|
|
14087
14566
|
if (live.hub) {
|
|
14088
14567
|
live.hub = false;
|
|
14568
|
+
live.leaving = 0;
|
|
14089
14569
|
await this.publishBeacon(group.id, live, now, false).catch(() => {});
|
|
14090
14570
|
}
|
|
14091
14571
|
await this.closeEntries(group.id);
|
|
@@ -14096,6 +14576,12 @@ var Communities = class {
|
|
|
14096
14576
|
continue;
|
|
14097
14577
|
}
|
|
14098
14578
|
if (live.session.status !== "active") {
|
|
14579
|
+
if (live.hub) {
|
|
14580
|
+
live.hub = false;
|
|
14581
|
+
await this.closeEntries(group.id);
|
|
14582
|
+
await this.publishBeacon(group.id, live, now, false).catch(() => {});
|
|
14583
|
+
}
|
|
14584
|
+
live.farewells.clear();
|
|
14099
14585
|
await this.reconcile(group.id, live, now);
|
|
14100
14586
|
continue;
|
|
14101
14587
|
}
|
|
@@ -14111,14 +14597,19 @@ var Communities = class {
|
|
|
14111
14597
|
live.lingering.delete(linkId);
|
|
14112
14598
|
if ([...this.host.entries(groupId).values()].includes(linkId)) await this.host.closeEdge(linkId);
|
|
14113
14599
|
}
|
|
14114
|
-
const alone = live.hub && !freshHubs(live
|
|
14115
|
-
if (now - live.lastBeaconRead >= (alone ? COMMUNITY_TOPOLOGY.beaconEveryMs : this.timings.beaconReadMs) || live.lastBeaconRead === 0) await this.readBeacon(groupId, live, now);
|
|
14116
|
-
|
|
14600
|
+
const alone = live.hub && !freshHubs(this.hubs(live), now).some((h) => h.key !== me);
|
|
14601
|
+
if ((live.beaconFailedAt ? now - live.beaconFailedAt >= BEACON_RETRY_MS : now - live.lastBeaconRead >= (alone ? COMMUNITY_TOPOLOGY.beaconEveryMs : this.timings.beaconReadMs)) || live.lastBeaconRead === 0) await this.readBeacon(groupId, live, now);
|
|
14602
|
+
if (!live.beaconKnown) {
|
|
14603
|
+
await this.keepLooking(groupId, live, now);
|
|
14604
|
+
await this.reconcile(groupId, live, now);
|
|
14605
|
+
return;
|
|
14606
|
+
}
|
|
14607
|
+
const others = freshHubs(this.hubs(live), now).filter((h) => h.key !== me);
|
|
14117
14608
|
if (!live.hub) {
|
|
14118
|
-
const settled = () => now - live.joinedAt >= this.timings.newcomerMs || !freshHubs(live
|
|
14119
|
-
const want = () => settled() && (shouldBeHub(me, live
|
|
14609
|
+
const settled = () => now - live.joinedAt >= this.timings.newcomerMs || !freshHubs(this.hubs(live), now).some((h) => h.key !== me);
|
|
14610
|
+
const want = () => settled() && (shouldBeHub(me, this.hubs(live), now) || !!live.forceHub && freshHubs(this.hubs(live), now).length < COMMUNITY_TOPOLOGY.maxHubs) && this.capacity(groupId, live, now) > 0;
|
|
14120
14611
|
if (want()) {
|
|
14121
|
-
if (!live.hubCandidateAt) live.hubCandidateAt = now + (freshHubs(live
|
|
14612
|
+
if (!live.hubCandidateAt) live.hubCandidateAt = now + (freshHubs(this.hubs(live), now).length ? Math.floor(this.random() * this.timings.hubJitterMs) : 0);
|
|
14122
14613
|
if (now >= live.hubCandidateAt) {
|
|
14123
14614
|
live.hubCandidateAt = 0;
|
|
14124
14615
|
if (live.lastBeaconRead !== now) await this.readBeacon(groupId, live, now);
|
|
@@ -14127,6 +14618,7 @@ var Communities = class {
|
|
|
14127
14618
|
live.hub = true;
|
|
14128
14619
|
live.hubSince = now;
|
|
14129
14620
|
live.emptySince = now;
|
|
14621
|
+
live.leaving = 0;
|
|
14130
14622
|
live.knocksScanned = false;
|
|
14131
14623
|
await this.publishBeacon(groupId, live, now, true).catch(() => {});
|
|
14132
14624
|
traceJoin(groupId, "hub.elected");
|
|
@@ -14137,15 +14629,45 @@ var Communities = class {
|
|
|
14137
14629
|
if (live.hub) {
|
|
14138
14630
|
const load = this.hubLoad(groupId, live, now);
|
|
14139
14631
|
if (load > 0) live.emptySince = now;
|
|
14140
|
-
const
|
|
14141
|
-
if (
|
|
14142
|
-
live.
|
|
14143
|
-
|
|
14144
|
-
|
|
14145
|
-
|
|
14632
|
+
const idle = now - live.emptySince > this.timings.idleHubMs && now - live.hubSince > this.timings.idleHubMs;
|
|
14633
|
+
if (live.leaving) {
|
|
14634
|
+
const read = live.beaconAt > live.leaving, listed = live.beacon.some((h) => h.key === me);
|
|
14635
|
+
if (read && idle && !listed && this.canStepDown(groupId, live, now)) {
|
|
14636
|
+
live.leaving = 0;
|
|
14637
|
+
live.hub = false;
|
|
14638
|
+
const edges = this.host.edges(groupId);
|
|
14639
|
+
live.myHubs = freshHubs(this.hubs(live), now).map((h) => h.key).filter((key) => {
|
|
14640
|
+
const id = edges.get(key);
|
|
14641
|
+
return key !== me && !!id && this.host.linkReady(id, 2);
|
|
14642
|
+
}).slice(0, COMMUNITY_TOPOLOGY.hubsPerMember);
|
|
14643
|
+
for (const key of live.myHubs) {
|
|
14644
|
+
live.hubsAvoided.delete(key);
|
|
14645
|
+
live.hubWaits.delete(key);
|
|
14646
|
+
live.hubsUp.add(key);
|
|
14647
|
+
}
|
|
14648
|
+
live.members.clear();
|
|
14649
|
+
} else if (read && idle && listed && this.canStepDown(groupId, live, now)) {
|
|
14650
|
+
if (await this.publishBeacon(groupId, live, now, false).then(() => true, () => false)) live.leaving = now;
|
|
14651
|
+
} else if (read || now - live.leaving > COMMUNITY_TOPOLOGY.beaconEveryMs) {
|
|
14652
|
+
live.leaving = 0;
|
|
14653
|
+
live.stepDownAt = now + this.timings.idleHubMs + Math.floor(this.random() * this.timings.idleHubMs);
|
|
14654
|
+
}
|
|
14655
|
+
} else if (idle && now >= live.stepDownAt && this.canStepDown(groupId, live, now)) {
|
|
14656
|
+
live.stepDownAt = now + 3 * BEACON_RETRY_MS;
|
|
14657
|
+
if ((live.beaconAt === now || await this.readBeacon(groupId, live, now)) && this.canStepDown(groupId, live, now) && await this.publishBeacon(groupId, live, now, false).then(() => true, () => false)) {
|
|
14658
|
+
live.leaving = now;
|
|
14659
|
+
await this.closeEntries(groupId);
|
|
14660
|
+
}
|
|
14661
|
+
}
|
|
14662
|
+
if (!live.hub || live.leaving) {} else if (now - live.lastBeaconTry < BEACON_RETRY_MS) {} else if (now - live.lastBeaconWrite >= COMMUNITY_TOPOLOGY.beaconEveryMs || !live.beacon.some((h) => h.key === me) || now - live.lastBeaconWrite >= 5e3 && Math.abs(this.beaconLoad(groupId, live, now) - (live.beacon.find((h) => h.key === me)?.load ?? load)) >= 8) await this.publishBeacon(groupId, live, now, true).catch(() => {});
|
|
14663
|
+
}
|
|
14664
|
+
if (live.hub) {
|
|
14665
|
+
for (const h of freshHubs(live.beacon, now)) if (h.ts + FAREWELL_SKEW_MS > (s.outAt(h.key) ?? Infinity)) this.wantFarewell(groupId, live, h.key, now);
|
|
14146
14666
|
}
|
|
14667
|
+
this.endFarewells(live, now);
|
|
14147
14668
|
if (live.hub) {
|
|
14148
14669
|
const busy = this.admitting(groupId, live, now);
|
|
14670
|
+
if (s.state.pendingLeaves.some((r) => r.s !== me && this.recentHub(live, r.s))) live.lobbyBusyUntil = Math.max(live.lobbyBusyUntil, now + this.timings.lobbyBusyMs);
|
|
14149
14671
|
const lobbyEvery = now < live.lobbyBusyUntil && !busy ? this.timings.lobbyPollMs : this.timings.lobbyIdlePollMs;
|
|
14150
14672
|
if (now - live.lastLobbyPoll >= lobbyEvery) {
|
|
14151
14673
|
live.lastLobbyPoll = now;
|
|
@@ -14157,26 +14679,41 @@ var Communities = class {
|
|
|
14157
14679
|
const edges = this.host.edges(groupId);
|
|
14158
14680
|
const fresh = new Set(others.map((h) => h.key));
|
|
14159
14681
|
for (const [key, until] of live.hubsAvoided) if (until <= now) live.hubsAvoided.delete(key);
|
|
14682
|
+
const gone = live.myHubs.filter((key) => s.wasRemoved(key) || s.state.pendingLeaves.some((r) => r.s === key));
|
|
14683
|
+
for (const key of gone) {
|
|
14684
|
+
live.hubsAvoided.set(key, now + HUB_GRACE_MS);
|
|
14685
|
+
live.hubWaits.delete(key);
|
|
14686
|
+
live.hubsUp.delete(key);
|
|
14687
|
+
}
|
|
14160
14688
|
for (const key of live.myHubs) {
|
|
14689
|
+
if (gone.includes(key)) continue;
|
|
14161
14690
|
const id = edges.get(key);
|
|
14162
14691
|
if (id && this.host.linkReady(id, 2)) {
|
|
14163
14692
|
live.hubWaits.delete(key);
|
|
14164
14693
|
live.hubsUp.add(key);
|
|
14694
|
+
live.replacing?.delete(key);
|
|
14165
14695
|
continue;
|
|
14166
14696
|
}
|
|
14167
14697
|
const since = live.hubWaits.get(key) ?? now;
|
|
14168
14698
|
live.hubWaits.set(key, since);
|
|
14169
14699
|
const back = live.hubsUp.has(key) && !!id && !!this.host.linkBack?.(id);
|
|
14170
|
-
if (now - since > (back ? 3 : 1) * this.timings.hubWaitMs) {
|
|
14700
|
+
if (now - since > (back || live.replacing?.has(key) ? 3 : 1) * this.timings.hubWaitMs) {
|
|
14171
14701
|
live.hubsAvoided.set(key, now + 2 * this.timings.hubWaitMs);
|
|
14172
14702
|
live.hubWaits.delete(key);
|
|
14173
14703
|
live.hubsUp.delete(key);
|
|
14704
|
+
live.replacing?.delete(key);
|
|
14174
14705
|
}
|
|
14175
14706
|
}
|
|
14176
14707
|
let kept = live.myHubs.filter((key) => (fresh.has(key) || this.recentHub(live, key)) && !live.hubsAvoided.has(key));
|
|
14177
14708
|
kept = kept.slice(0, COMMUNITY_TOPOLOGY.hubsPerMember);
|
|
14178
14709
|
const picked = pickHubs(me, others, now, new Set(live.hubsAvoided.keys()));
|
|
14179
|
-
for (const key of picked) if (kept.length < Math.max(1, picked.length) && !kept.includes(key))
|
|
14710
|
+
for (const key of picked) if (kept.length < Math.max(1, picked.length) && !kept.includes(key)) {
|
|
14711
|
+
kept.push(key);
|
|
14712
|
+
if (gone.length) {
|
|
14713
|
+
if (!edges.has(key)) live.expect.add(key);
|
|
14714
|
+
(live.replacing ??= /* @__PURE__ */ new Set()).add(key);
|
|
14715
|
+
}
|
|
14716
|
+
}
|
|
14180
14717
|
live.myHubs = kept;
|
|
14181
14718
|
live.forceHub = !kept.length && others.length < COMMUNITY_TOPOLOGY.maxHubs;
|
|
14182
14719
|
for (const hub of kept) {
|
|
@@ -14200,7 +14737,7 @@ var Communities = class {
|
|
|
14200
14737
|
}
|
|
14201
14738
|
if (s.needsCatchUp && now - (live.lastCatchUp ?? 0) >= 5e3 || now - (live.lastCatchUp ?? 0) >= 3e4) {
|
|
14202
14739
|
live.lastCatchUp = now;
|
|
14203
|
-
const ready = [...this.host.edges(groupId)].filter(([, id]) => this.host.linkReady(id, 2)).map(([key]) => key);
|
|
14740
|
+
const ready = [...this.host.edges(groupId)].filter(([key, id]) => this.host.linkReady(id, 2) && !s.wasRemoved(key)).map(([key]) => key);
|
|
14204
14741
|
for (const key of ready.slice(0, 3)) s.catchUp(key);
|
|
14205
14742
|
}
|
|
14206
14743
|
await this.reconcile(groupId, live, now);
|
|
@@ -14226,8 +14763,32 @@ var Communities = class {
|
|
|
14226
14763
|
if (id && !this.host.linkReady(id, 2)) this.host.expectPeer(id);
|
|
14227
14764
|
}
|
|
14228
14765
|
}
|
|
14766
|
+
/**
|
|
14767
|
+
* May this idle hub step down? When enough other hubs are listed and it has an edge up to one that is listed now
|
|
14768
|
+
* (a hub cut off from the others would only come back as one). It then steps down in two steps: it takes itself
|
|
14769
|
+
* out of the beacon, still a hub, and is a member only once a later reading still lists a hub it has an edge to.
|
|
14770
|
+
* Before, it was a member at once, and counted as hubs those it remembered from earlier readings. Hubs elected in
|
|
14771
|
+
* the same seconds (the members of a hub that left) were idle in the same second a minute later, and two of them
|
|
14772
|
+
* with an edge to each other only both stepped down, each counting on the other: each was the other's hub, neither
|
|
14773
|
+
* was one, and the two heard nobody else until the memory of them ran out, minutes later. And a hub whose write the
|
|
14774
|
+
* relays held back was no hub any more but stayed listed, so members went on asking it (2026-10-01).
|
|
14775
|
+
*/
|
|
14776
|
+
canStepDown(groupId, live, now) {
|
|
14777
|
+
const me = live.session.myKey, others = freshHubs(this.hubs(live), now).filter((h) => h.key !== me);
|
|
14778
|
+
if (others.length < COMMUNITY_TOPOLOGY.minHubs) return false;
|
|
14779
|
+
const edges = this.host.edges(groupId);
|
|
14780
|
+
return others.some((h) => {
|
|
14781
|
+
const id = edges.get(h.key);
|
|
14782
|
+
return !!id && this.host.linkReady(id, 2);
|
|
14783
|
+
});
|
|
14784
|
+
}
|
|
14229
14785
|
hubLoad(groupId, live, now) {
|
|
14230
14786
|
const edges = this.host.edges(groupId);
|
|
14787
|
+
if (now - live.beaconAt <= 2 * this.timings.beaconReadMs) {
|
|
14788
|
+
const hubs = new Set(freshHubs(this.hubs(live), now).map((h) => h.key));
|
|
14789
|
+
for (const [key, id] of edges) if (!hubs.has(key) && !live.members.has(key) && this.host.linkReady(id, 2) && !live.session.wasRemoved(key)) live.members.set(key, now);
|
|
14790
|
+
for (const key of [...live.members.keys()]) if (hubs.has(key)) live.members.delete(key);
|
|
14791
|
+
}
|
|
14231
14792
|
for (const [key, since] of live.members) {
|
|
14232
14793
|
const id = edges.get(key);
|
|
14233
14794
|
if (id && this.host.linkReady(id, 2)) live.members.set(key, now);
|
|
@@ -14258,7 +14819,7 @@ var Communities = class {
|
|
|
14258
14819
|
capacity(groupId, live, now, entries = true) {
|
|
14259
14820
|
const room = this.host.peerRoom?.(groupId);
|
|
14260
14821
|
if (room === void 0) return COMMUNITY_TOPOLOGY.hubCapacity;
|
|
14261
|
-
const hubs = new Set(freshHubs(live
|
|
14822
|
+
const hubs = new Set(freshHubs(this.hubs(live), now).map((h) => h.key));
|
|
14262
14823
|
for (const key of this.host.edges(groupId).keys()) if (!live.members.has(key)) hubs.add(key);
|
|
14263
14824
|
hubs.delete(live.session.myKey);
|
|
14264
14825
|
const held = hubs.size + (entries ? this.host.entries(groupId).size : 0);
|
|
@@ -14269,13 +14830,72 @@ var Communities = class {
|
|
|
14269
14830
|
const n = live.members.size;
|
|
14270
14831
|
return n >= this.capacity(groupId, live, now, false) ? Math.max(n, COMMUNITY_TOPOLOGY.hubCapacity) : n;
|
|
14271
14832
|
}
|
|
14833
|
+
/** Reads the beacon; false when the reading did not happen (the relays held it back, or none answered). */
|
|
14272
14834
|
async readBeacon(groupId, live, now) {
|
|
14273
14835
|
live.lastBeaconRead = now;
|
|
14274
14836
|
const keys = beaconKeys(live.session.state.rv, groupId);
|
|
14275
|
-
const records = await this.host.resolve(keys.identity.pubKeyZ32, true).catch(() =>
|
|
14276
|
-
|
|
14837
|
+
const records = await this.host.resolve(keys.identity.pubKeyZ32, true).catch(() => void 0);
|
|
14838
|
+
if (records === void 0) {
|
|
14839
|
+
live.beaconFailedAt = now;
|
|
14840
|
+
live.beaconAskedAt ||= now;
|
|
14841
|
+
if (live.session.roster.length === 1 || now - live.beaconAskedAt >= COMMUNITY_TOPOLOGY.beaconFreshMs) live.beaconKnown = true;
|
|
14842
|
+
return false;
|
|
14843
|
+
}
|
|
14844
|
+
live.beaconFailedAt = 0;
|
|
14845
|
+
live.beaconKnown = true;
|
|
14846
|
+
live.beacon = this.inMyTime(live, readBeacon(keys, records ?? []), now);
|
|
14277
14847
|
live.head = readBeaconHead(keys, records ?? []);
|
|
14848
|
+
live.beaconAt = now;
|
|
14278
14849
|
this.noteHubs(live, now);
|
|
14850
|
+
return true;
|
|
14851
|
+
}
|
|
14852
|
+
/**
|
|
14853
|
+
* A reading of the beacon in this app's time. A hub dates its entry by its own clock; held against mine as it is, a
|
|
14854
|
+
* hub whose clock is a minute and a half from mine never looks fresh (its entry is "stale", or "not come yet"): a
|
|
14855
|
+
* member saw no hub and took itself for the only one, and a hub with such a clock was no hub to anyone. So a hub's
|
|
14856
|
+
* clock is learned from its entry: when the entry moved between two readings of mine, it was written between them,
|
|
14857
|
+
* and what it says the time was then, against my own, is how far the two clocks are apart. An entry of a clock
|
|
14858
|
+
* further than `HUB_CLOCK_SLACK_MS` from mine is moved by that much (its `since` with it); one closer, or one not
|
|
14859
|
+
* seen to move yet, is taken as written, as before. An entry that stops moving ages by my clock like any other.
|
|
14860
|
+
*/
|
|
14861
|
+
inMyTime(live, read, now) {
|
|
14862
|
+
const gap = now - live.beaconSeenAt;
|
|
14863
|
+
for (const h of read) {
|
|
14864
|
+
if (h.key === live.session.myKey) continue;
|
|
14865
|
+
const known = live.hubClocks.get(h.key);
|
|
14866
|
+
const moved = !!known && h.ts > known.said && live.beaconSeenAt > 0 && gap <= HUB_CLOCK_READ_GAP_MS;
|
|
14867
|
+
live.hubClocks.set(h.key, {
|
|
14868
|
+
said: Math.max(h.ts, known?.said ?? 0),
|
|
14869
|
+
ahead: moved ? now - gap / 2 - h.ts : known?.ahead,
|
|
14870
|
+
at: now
|
|
14871
|
+
});
|
|
14872
|
+
}
|
|
14873
|
+
for (const [key, clock] of live.hubClocks) if (now - clock.at > HUB_CLOCK_KEPT_MS) live.hubClocks.delete(key);
|
|
14874
|
+
live.beaconSeenAt = now;
|
|
14875
|
+
return read.map((h) => {
|
|
14876
|
+
const ahead = this.clockAhead(live, h.key);
|
|
14877
|
+
return ahead ? {
|
|
14878
|
+
...h,
|
|
14879
|
+
ts: h.ts + ahead,
|
|
14880
|
+
...h.since !== void 0 && { since: h.since + ahead }
|
|
14881
|
+
} : h;
|
|
14882
|
+
});
|
|
14883
|
+
}
|
|
14884
|
+
/** How far my clock is ahead of a hub's (ms, negative when behind), when the two differ; 0 when they agree or nothing says. */
|
|
14885
|
+
clockAhead(live, hub) {
|
|
14886
|
+
const ahead = hub === live.session.myKey ? 0 : live.hubClocks.get(hub)?.ahead ?? 0;
|
|
14887
|
+
return Math.abs(ahead) > HUB_CLOCK_SLACK_MS ? Math.round(ahead) : 0;
|
|
14888
|
+
}
|
|
14889
|
+
/** Entries in my time, as each hub wrote them: what goes back into the beacon, so no hub's entry is dated by another's clock. */
|
|
14890
|
+
asWritten(live, hubs) {
|
|
14891
|
+
return hubs.map((h) => {
|
|
14892
|
+
const ahead = this.clockAhead(live, h.key);
|
|
14893
|
+
return ahead ? {
|
|
14894
|
+
...h,
|
|
14895
|
+
ts: h.ts - ahead,
|
|
14896
|
+
...h.since !== void 0 && { since: h.since - ahead }
|
|
14897
|
+
} : h;
|
|
14898
|
+
});
|
|
14279
14899
|
}
|
|
14280
14900
|
/**
|
|
14281
14901
|
* The newest message frame I hold, for the beacon's head (WISP 9xx § Head): what a member's other profile on the
|
|
@@ -14296,9 +14916,11 @@ var Communities = class {
|
|
|
14296
14916
|
live.lastBeaconTry = now;
|
|
14297
14917
|
const keys = beaconKeys(live.session.state.rv, groupId);
|
|
14298
14918
|
let existing = live.beacon, head = live.head;
|
|
14299
|
-
if (live.lastBeaconRead !== now) {
|
|
14300
|
-
const
|
|
14301
|
-
|
|
14919
|
+
if (live.lastBeaconRead !== now || live.beaconFailedAt === now) {
|
|
14920
|
+
const read = await this.host.resolve(keys.identity.pubKeyZ32, true).catch(() => void 0);
|
|
14921
|
+
if (read === void 0 && live.session.roster.length > 1) throw new Error("The beacon could not be read; not published");
|
|
14922
|
+
const records = read ?? [];
|
|
14923
|
+
existing = this.inMyTime(live, readBeacon(keys, records), now);
|
|
14302
14924
|
head = readBeaconHead(keys, records);
|
|
14303
14925
|
}
|
|
14304
14926
|
const hubs = mergeBeacon(existing, live.session.myKey, listed ? {
|
|
@@ -14308,15 +14930,80 @@ var Communities = class {
|
|
|
14308
14930
|
since: live.hubSince || now
|
|
14309
14931
|
} : null, now, (key) => !live.session.wasRemoved(key));
|
|
14310
14932
|
const newest = newerHead(head, this.newestFrame(live), now);
|
|
14311
|
-
await this.host.publish(keys.identity, beaconRecords(keys, hubs, newest), true);
|
|
14933
|
+
await this.host.publish(keys.identity, beaconRecords(keys, this.asWritten(live, hubs), newest), true);
|
|
14312
14934
|
live.beacon = hubs;
|
|
14313
14935
|
live.head = newest;
|
|
14314
14936
|
this.noteHubs(live, now);
|
|
14315
14937
|
live.lastBeaconWrite = now;
|
|
14316
|
-
live.lastBeaconRead = now;
|
|
14938
|
+
live.lastBeaconRead = live.beaconAt = now;
|
|
14939
|
+
live.beaconFailedAt = 0;
|
|
14940
|
+
live.beaconKnown = true;
|
|
14941
|
+
}
|
|
14942
|
+
/**
|
|
14943
|
+
* The beacon's hubs, less those the chain took out: hubs are members. Someone removed while its app was closed lists
|
|
14944
|
+
* itself there when it comes back, believing it is a member, until it is told (`wantFarewell`).
|
|
14945
|
+
*/
|
|
14946
|
+
hubs(live) {
|
|
14947
|
+
const top = live.session.top, memo = live.hubsMemo;
|
|
14948
|
+
if (memo && memo.beacon === live.beacon && memo.top === top) return memo.hubs;
|
|
14949
|
+
const hubs = live.beacon.filter((h) => !live.session.wasRemoved(h.key));
|
|
14950
|
+
live.hubsMemo = {
|
|
14951
|
+
beacon: live.beacon,
|
|
14952
|
+
top,
|
|
14953
|
+
hubs
|
|
14954
|
+
};
|
|
14955
|
+
return hubs;
|
|
14956
|
+
}
|
|
14957
|
+
/** A frame goes on an edge to a member; to someone the chain took out, only the commit that took it out. */
|
|
14958
|
+
mayHear(live, key, frame) {
|
|
14959
|
+
if (!live?.session.wasRemoved(key)) return true;
|
|
14960
|
+
const f = frame;
|
|
14961
|
+
return f.t === "group-commit" && (f.commit?.k === "remove" || f.commit?.k === "leave") && f.commit.s === key;
|
|
14962
|
+
}
|
|
14963
|
+
/** The edges of the group that may hear `frame`. */
|
|
14964
|
+
hearers(groupId, frame) {
|
|
14965
|
+
const live = this.live.get(groupId);
|
|
14966
|
+
return [...this.host.edges(groupId)].filter(([key]) => this.mayHear(live, key, frame));
|
|
14967
|
+
}
|
|
14968
|
+
/** As a hub: someone the chain took out is asking (in my lobby, or listing itself as a hub). An edge to tell it, within bounds. */
|
|
14969
|
+
wantFarewell(groupId, live, key, now) {
|
|
14970
|
+
if (key === live.session.myKey || live.farewells.has(key) || now < (live.farewellQuiet.get(key) ?? 0)) return;
|
|
14971
|
+
if (live.farewells.size >= FAREWELL_EDGES || this.capacity(groupId, live, now) <= 0) return;
|
|
14972
|
+
live.farewells.set(key, { since: now });
|
|
14973
|
+
if (!this.host.edges(groupId).has(key)) live.expect.add(key);
|
|
14974
|
+
}
|
|
14975
|
+
/** Farewell edges end: told a moment ago, never came, or back in the group; and a hub no more keeps none. */
|
|
14976
|
+
endFarewells(live, now) {
|
|
14977
|
+
for (const [key, f] of live.farewells) {
|
|
14978
|
+
const back = !live.session.wasRemoved(key);
|
|
14979
|
+
if (!back && live.hub && (f.told ? now - f.told < FAREWELL_LINGER_MS : now - f.since < FAREWELL_WAIT_MS)) continue;
|
|
14980
|
+
live.farewells.delete(key);
|
|
14981
|
+
if (!back) live.farewellQuiet.set(key, now + (f.told ? FAREWELL_TOLD_MS : FAREWELL_RETRY_MS));
|
|
14982
|
+
}
|
|
14983
|
+
for (const [key, until] of live.farewellQuiet) if (until <= now) live.farewellQuiet.delete(key);
|
|
14984
|
+
for (const [key, at] of live.farewellSaid) if (now - at >= FAREWELL_SAY_MS) live.farewellSaid.delete(key);
|
|
14985
|
+
}
|
|
14986
|
+
/**
|
|
14987
|
+
* Someone the chain took out says where it is (`group-sync`): it is handed the commits up to the one that took it
|
|
14988
|
+
* out, and nothing else (`CommunitySession.farewell`). Nothing else it says is heard.
|
|
14989
|
+
*/
|
|
14990
|
+
sayFarewell(groupId, live, key, sync) {
|
|
14991
|
+
const now = this.now(), linkId = this.host.edges(groupId).get(key);
|
|
14992
|
+
if (!linkId || now - (live.farewellSaid.get(key) ?? -Infinity) < FAREWELL_SAY_MS) return;
|
|
14993
|
+
const frames = live.session.farewell(key, sync);
|
|
14994
|
+
if (!frames.length) return;
|
|
14995
|
+
live.farewellSaid.set(key, now);
|
|
14996
|
+
try {
|
|
14997
|
+
for (const frame of frames) this.host.sendOnLink(linkId, frame);
|
|
14998
|
+
} catch {
|
|
14999
|
+
return;
|
|
15000
|
+
}
|
|
15001
|
+
const waited = live.farewells.get(key);
|
|
15002
|
+
if (waited) waited.told = now;
|
|
15003
|
+
traceJoin(groupId, "farewell.sent");
|
|
14317
15004
|
}
|
|
14318
15005
|
noteHubs(live, now) {
|
|
14319
|
-
for (const h of freshHubs(live
|
|
15006
|
+
for (const h of freshHubs(this.hubs(live), now)) live.seenHubs.set(h.key, now);
|
|
14320
15007
|
for (const [key, at] of live.seenHubs) if (now - at > HUB_GRACE_MS) live.seenHubs.delete(key);
|
|
14321
15008
|
}
|
|
14322
15009
|
recentHub(live, key) {
|
|
@@ -14326,17 +15013,22 @@ var Communities = class {
|
|
|
14326
15013
|
async askHub(groupId, live, hub, now) {
|
|
14327
15014
|
const keys = lobbyKeys(live.session.state.rv, groupId, hub);
|
|
14328
15015
|
const existing = readLobby(keys, await this.host.resolve(keys.identity.pubKeyZ32, true) ?? []);
|
|
15016
|
+
const theirs = now - this.clockAhead(live, hub);
|
|
14329
15017
|
await this.host.publish(keys.identity, lobbyRecords(keys, mergeLobby(existing, {
|
|
14330
15018
|
key: live.session.myKey,
|
|
14331
|
-
ts:
|
|
14332
|
-
},
|
|
15019
|
+
ts: theirs
|
|
15020
|
+
}, theirs)), true);
|
|
14333
15021
|
traceJoin(groupId, "lobby.written");
|
|
14334
15022
|
}
|
|
14335
15023
|
async pollLobby(groupId, live, now) {
|
|
14336
15024
|
const keys = lobbyKeys(live.session.state.rv, groupId, live.session.myKey);
|
|
14337
15025
|
const entries = readLobby(keys, await this.host.resolve(keys.identity.pubKeyZ32, true).catch(() => null) ?? []);
|
|
14338
15026
|
for (const { key, ts } of entries) {
|
|
14339
|
-
if (now - ts > COMMUNITY_TOPOLOGY.lobbyFreshMs || key === live.session.myKey
|
|
15027
|
+
if (now - ts > COMMUNITY_TOPOLOGY.lobbyFreshMs || key === live.session.myKey) continue;
|
|
15028
|
+
if (live.session.wasRemoved(key)) {
|
|
15029
|
+
if (ts + FAREWELL_SKEW_MS > (live.session.outAt(key) ?? Infinity)) this.wantFarewell(groupId, live, key, now);
|
|
15030
|
+
continue;
|
|
15031
|
+
}
|
|
14340
15032
|
live.emptySince = now;
|
|
14341
15033
|
if (!live.members.has(key) && live.members.size >= this.capacity(groupId, live, now)) continue;
|
|
14342
15034
|
if (!live.members.has(key)) {
|
|
@@ -14353,10 +15045,11 @@ var Communities = class {
|
|
|
14353
15045
|
if (s.status === "active") {
|
|
14354
15046
|
if (live.hub) {
|
|
14355
15047
|
this.shed(groupId, live, now);
|
|
14356
|
-
for (const h of freshHubs(live
|
|
15048
|
+
for (const h of freshHubs(this.hubs(live), now)) if (h.key !== s.myKey) wanted.add(h.key);
|
|
14357
15049
|
for (const key of live.members.keys()) wanted.add(key);
|
|
15050
|
+
for (const key of live.farewells.keys()) wanted.add(key);
|
|
14358
15051
|
} else for (const key of live.myHubs) wanted.add(key);
|
|
14359
|
-
for (const [key, id] of existing) if (this.host.linkReady(id, 2) && (live.members.has(key) || this.recentHub(live, key))) wanted.add(key);
|
|
15052
|
+
for (const [key, id] of existing) if (this.host.linkReady(id, 2) && (live.members.has(key) || this.recentHub(live, key)) && !s.wasRemoved(key)) wanted.add(key);
|
|
14360
15053
|
}
|
|
14361
15054
|
for (const [key, since] of live.restored) {
|
|
14362
15055
|
if (!since) live.restored.set(key, now);
|
|
@@ -14411,7 +15104,7 @@ var Communities = class {
|
|
|
14411
15104
|
g: groupId,
|
|
14412
15105
|
host: s.entryKey
|
|
14413
15106
|
};
|
|
14414
|
-
const hubs = doorHubs(live
|
|
15107
|
+
const hubs = doorHubs(this.hubs(live), now, s.myKey);
|
|
14415
15108
|
if (!hubs.includes(s.myKey)) return;
|
|
14416
15109
|
const door = [...hubs].sort()[0], doorSig = [...hubs].sort().join(",");
|
|
14417
15110
|
if (live.doors !== doorSig) {
|
|
@@ -14433,7 +15126,7 @@ var Communities = class {
|
|
|
14433
15126
|
ts
|
|
14434
15127
|
}));
|
|
14435
15128
|
for (const { key, ts } of knocks) {
|
|
14436
|
-
if (now - ts > 18e4 || this.refused.has(key) || live.pendingEntries.has(key) || key === link.host) continue;
|
|
15129
|
+
if (now - ts > 18e4 || this.refused.has(key) || live.pendingEntries.has(key) || key === link.host || key === s.myKey) continue;
|
|
14437
15130
|
const stillKnocking = now - ts < 2 * this.timings.slowKnockMs;
|
|
14438
15131
|
if (rosterHas(s.roster, key)) {
|
|
14439
15132
|
const added = [...s.state.chain].reverse().find((c) => c.k === "add" && c.s === key)?.ts ?? 0;
|
|
@@ -14510,14 +15203,23 @@ var Communities = class {
|
|
|
14510
15203
|
ms: Date.now() - started,
|
|
14511
15204
|
record: n
|
|
14512
15205
|
});
|
|
14513
|
-
|
|
15206
|
+
const others = knocks.filter((k) => k.key !== me && now - k.ts < 2 * this.timings.slowKnockMs).length;
|
|
15207
|
+
const moved = (this.knockingWith.get(group.id) ?? 0) !== others;
|
|
15208
|
+
this.knockingWith.set(group.id, others);
|
|
15209
|
+
if (!this.knocked.has(group.id) || moved) {
|
|
14514
15210
|
this.knocked.add(group.id);
|
|
14515
15211
|
this.host.emit();
|
|
14516
15212
|
}
|
|
14517
15213
|
}
|
|
15214
|
+
/**
|
|
15215
|
+
* The entry sessions this device runs for joiners go (it is no door any more). Its own, when it is knocking at the
|
|
15216
|
+
* link itself, stays: a hub taken out of the group that opened the link again at once had that session closed by
|
|
15217
|
+
* its next tick (it stops being a hub and a door there), and knocked for good with nothing for a member to answer on.
|
|
15218
|
+
*/
|
|
14518
15219
|
async closeEntries(groupId) {
|
|
14519
|
-
this.live.get(groupId)
|
|
14520
|
-
|
|
15220
|
+
const live = this.live.get(groupId), mine = this.stored.get(groupId)?.joining?.linkId;
|
|
15221
|
+
live?.pendingEntries.clear();
|
|
15222
|
+
for (const linkId of this.host.entries(groupId).values()) if (linkId !== mine) await this.host.closeEdge(linkId);
|
|
14521
15223
|
}
|
|
14522
15224
|
/** An entry session of a community came up with groups v2 on both sides: the hub invites. */
|
|
14523
15225
|
entryReady(groupId, linkId, peer) {
|
|
@@ -14602,7 +15304,16 @@ var Communities = class {
|
|
|
14602
15304
|
return;
|
|
14603
15305
|
}
|
|
14604
15306
|
traceJoin(g, "welcome.received");
|
|
14605
|
-
const
|
|
15307
|
+
const before = this.live.get(g)?.session, again = !!before;
|
|
15308
|
+
if (before?.status === "active" && before.isMember) {
|
|
15309
|
+
const mine = (chain) => [...chain].reverse().find((c) => c.k === "add" && c.s === before.myKey)?.sig;
|
|
15310
|
+
if (mine(joined.state.chain) === mine(before.state.chain)) {
|
|
15311
|
+
await this.endCheck(group);
|
|
15312
|
+
return;
|
|
15313
|
+
}
|
|
15314
|
+
await this.event(g, "removed", "You were removed from this group", this.now(), joined.state.chain.length - 1);
|
|
15315
|
+
}
|
|
15316
|
+
const back = !!before && before.status !== "lost";
|
|
14606
15317
|
for (const [, id] of this.host.entries(g)) if (id !== linkId) await this.host.closeEdge(id);
|
|
14607
15318
|
const member = {
|
|
14608
15319
|
id: g,
|
|
@@ -14618,11 +15329,19 @@ var Communities = class {
|
|
|
14618
15329
|
live.expect.add(joining.inviter);
|
|
14619
15330
|
live.lobbyWrites.set(joining.inviter, now);
|
|
14620
15331
|
live.joinedAt = now;
|
|
15332
|
+
const admission = [...joined.state.chain].reverse().find((c) => c.k === "add" && c.s === live.session.myKey);
|
|
15333
|
+
if (admission?.by === joining.inviter) live.hubClocks.set(joining.inviter, {
|
|
15334
|
+
said: 0,
|
|
15335
|
+
ahead: now - admission.ts,
|
|
15336
|
+
at: now
|
|
15337
|
+
});
|
|
14621
15338
|
await this.store.putGroup(member);
|
|
14622
15339
|
if (!again) await this.event(g, "joined", `You joined. ${GROUP_READ_NOTE_COMMUNITY}`, now, joined.state.chain.length - 1);
|
|
15340
|
+
else if (back) await this.event(g, "joined", "You joined again", now, joined.state.chain.length - 1);
|
|
14623
15341
|
this.lastKnock.delete(g);
|
|
14624
15342
|
this.knocked.delete(g);
|
|
14625
15343
|
this.knockAt.delete(g);
|
|
15344
|
+
this.knockingWith.delete(g);
|
|
14626
15345
|
await this.host.closeEdge(linkId);
|
|
14627
15346
|
await this.reconcile(g, live, now);
|
|
14628
15347
|
this.host.emit();
|
|
@@ -14642,12 +15361,17 @@ var Communities = class {
|
|
|
14642
15361
|
/** A frame on an edge, from the member it is pinned to: handled, then relayed on by a hub. */
|
|
14643
15362
|
async handleEdgeFrame(groupId, peerKey, frame) {
|
|
14644
15363
|
const live = this.live.get(groupId);
|
|
14645
|
-
if (!live || !frame || typeof frame !== "object"
|
|
15364
|
+
if (!live || !frame || typeof frame !== "object") return;
|
|
14646
15365
|
const f = frame;
|
|
15366
|
+
if (live.session.wasRemoved(peerKey)) {
|
|
15367
|
+
if (f.t === "group-sync" && live.session.isMember) this.sayFarewell(groupId, live, peerKey, f);
|
|
15368
|
+
return;
|
|
15369
|
+
}
|
|
14647
15370
|
if ((f.t === "group-secret" || f.t === "group-entry") && typeof f.to === "string" && f.to !== live.session.myKey) {
|
|
14648
15371
|
if (!live.hub) return;
|
|
14649
15372
|
const id = `${String(f.t)}:${f.to}:${String(f.h ?? f.x)}`;
|
|
14650
15373
|
if (!this.remember(live, id)) return;
|
|
15374
|
+
if (live.session.wasRemoved(f.to)) return;
|
|
14651
15375
|
const edges = this.host.edges(groupId), direct = edges.get(f.to);
|
|
14652
15376
|
if (direct && this.host.linkReady(direct, 2)) {
|
|
14653
15377
|
this.sendTo(direct, f);
|
|
@@ -14657,10 +15381,10 @@ var Communities = class {
|
|
|
14657
15381
|
return;
|
|
14658
15382
|
}
|
|
14659
15383
|
if (!await live.session.handle(peerKey, frame) || !live.hub) return;
|
|
14660
|
-
for (const [key, id] of this.
|
|
15384
|
+
for (const [key, id] of this.hearers(groupId, f)) if (key !== peerKey) this.sendTo(id, f);
|
|
14661
15385
|
}
|
|
14662
15386
|
peerIsHub(live, key) {
|
|
14663
|
-
return this.recentHub(live, key) && !live.members.has(key);
|
|
15387
|
+
return this.recentHub(live, key) && !live.members.has(key) && !live.session.wasRemoved(key);
|
|
14664
15388
|
}
|
|
14665
15389
|
remember(live, id) {
|
|
14666
15390
|
if (live.relayed.has(id)) return false;
|
|
@@ -14668,14 +15392,16 @@ var Communities = class {
|
|
|
14668
15392
|
if (live.relayed.size > RELAYED_KEPT) live.relayed.delete(live.relayed.values().next().value);
|
|
14669
15393
|
return true;
|
|
14670
15394
|
}
|
|
15395
|
+
/** True when the edge took the frame. */
|
|
14671
15396
|
sendTo(linkId, frame) {
|
|
14672
|
-
if (!linkId || !this.host.linkReady(linkId, 2)) return;
|
|
15397
|
+
if (!linkId || !this.host.linkReady(linkId, 2)) return false;
|
|
14673
15398
|
try {
|
|
14674
15399
|
this.host.sendOnLink(linkId, frame);
|
|
14675
15400
|
} catch {
|
|
14676
|
-
return;
|
|
15401
|
+
return false;
|
|
14677
15402
|
}
|
|
14678
15403
|
this.noteTaken(frame);
|
|
15404
|
+
return true;
|
|
14679
15405
|
}
|
|
14680
15406
|
/** My frame went out on an edge (at first, or in a catch-up): the message it is, or the edit it carries, was taken. */
|
|
14681
15407
|
noteTaken(frame) {
|
|
@@ -14710,22 +15436,23 @@ var Communities = class {
|
|
|
14710
15436
|
group.community = next;
|
|
14711
15437
|
await this.store.putGroup(group);
|
|
14712
15438
|
},
|
|
14713
|
-
broadcast: (frame) =>
|
|
14714
|
-
|
|
15439
|
+
broadcast: (frame) => this.hearers(id, frame).filter(([, linkId]) => this.sendTo(linkId, frame)).length,
|
|
15440
|
+
direct: (to, frame) => {
|
|
15441
|
+
if (this.mayHear(this.live.get(id), to, frame)) this.sendTo(this.host.edges(id).get(to), frame);
|
|
14715
15442
|
},
|
|
14716
|
-
direct: (to, frame) => this.sendTo(this.host.edges(id).get(to), frame),
|
|
14717
15443
|
addressed: (to, frame) => {
|
|
14718
15444
|
const edges = this.host.edges(id), live = this.live.get(id);
|
|
14719
15445
|
const direct = edges.get(to);
|
|
15446
|
+
if (live?.session.wasRemoved(to)) return;
|
|
14720
15447
|
if (direct && this.host.linkReady(direct, 2)) {
|
|
14721
15448
|
this.sendTo(direct, frame);
|
|
14722
15449
|
return;
|
|
14723
15450
|
}
|
|
14724
15451
|
const hubs = new Set(live ? [...live.seenHubs.keys()].filter((key) => this.recentHub(live, key)) : []);
|
|
14725
|
-
for (const [key, linkId] of edges) if (hubs.has(key) || live?.myHubs.includes(key)) this.sendTo(linkId, frame);
|
|
15452
|
+
for (const [key, linkId] of edges) if ((hubs.has(key) || live?.myHubs.includes(key)) && !live?.session.wasRemoved(key)) this.sendTo(linkId, frame);
|
|
14726
15453
|
},
|
|
14727
15454
|
message: async (m) => {
|
|
14728
|
-
const timestamp =
|
|
15455
|
+
const timestamp = m.timestamp;
|
|
14729
15456
|
const mentioned = m.sender !== session.myKey && mentionsMember(m.mentions, session.myKey);
|
|
14730
15457
|
const stored = await this.host.storeMessage({
|
|
14731
15458
|
linkId: MESSAGE_LINK$1(id),
|
|
@@ -14740,26 +15467,28 @@ var Communities = class {
|
|
|
14740
15467
|
...m.forwarded && { forwarded: m.forwarded },
|
|
14741
15468
|
...m.card && { card: m.card }
|
|
14742
15469
|
});
|
|
14743
|
-
|
|
14744
|
-
|
|
15470
|
+
const came = m.sender === session.myKey ? timestamp : cameAt(timestamp, stored, arrivalNow(this.now()));
|
|
15471
|
+
this.lastMessageAt.set(id, Math.max(this.lastMessageAt.get(id) ?? 0, came));
|
|
14745
15472
|
if (mentioned) this.lastMentionAt.set(id, Math.max(this.lastMentionAt.get(id) ?? 0, came));
|
|
14746
15473
|
if (m.sender !== session.myKey) this.lastPeerMessageAt.set(id, Math.max(this.lastPeerMessageAt.get(id) ?? 0, came));
|
|
15474
|
+
noteCame(this.stored.get(id), came, m.sender !== session.myKey, mentioned);
|
|
14747
15475
|
},
|
|
14748
15476
|
app: (m) => this.deliver(id, () => this.host.communityApp?.(id, m.sender, m.frame)),
|
|
14749
15477
|
pair: (m) => this.deliver(id, () => this.host.communityPair?.(id, m.sender, m.payload)),
|
|
14750
15478
|
changed: () => {
|
|
14751
15479
|
this.membershipChanged(id);
|
|
14752
15480
|
},
|
|
14753
|
-
metaChanged: (by, change) => {
|
|
15481
|
+
metaChanged: (by, change, at) => {
|
|
14754
15482
|
const name = by === session.myKey ? "You" : session.state.nicks[by] ?? `Member ${by.slice(0, 8)}`;
|
|
15483
|
+
const when = receivedTimestamp(at, this.now());
|
|
14755
15484
|
(async () => {
|
|
14756
|
-
for (const line of metaLines(name, change, session.name)) await this.event(id, line.event, line.text,
|
|
15485
|
+
for (const line of metaLines(name, change, session.name)) await this.event(id, line.event, line.text, when, session.epoch, by);
|
|
14757
15486
|
this.host.emit();
|
|
14758
15487
|
})();
|
|
14759
15488
|
},
|
|
14760
15489
|
clock: () => this.now(),
|
|
14761
15490
|
relay: (frame) => {
|
|
14762
|
-
if (this.live.get(id)?.hub) for (const linkId of this.
|
|
15491
|
+
if (this.live.get(id)?.hub) for (const [, linkId] of this.hearers(id, frame)) this.sendTo(linkId, frame);
|
|
14763
15492
|
}
|
|
14764
15493
|
});
|
|
14765
15494
|
this.live.set(id, {
|
|
@@ -14767,8 +15496,15 @@ var Communities = class {
|
|
|
14767
15496
|
hub: false,
|
|
14768
15497
|
hubSince: 0,
|
|
14769
15498
|
beacon: [],
|
|
15499
|
+
hubClocks: /* @__PURE__ */ new Map(),
|
|
15500
|
+
beaconSeenAt: 0,
|
|
14770
15501
|
head: null,
|
|
14771
15502
|
lastBeaconRead: 0,
|
|
15503
|
+
beaconKnown: false,
|
|
15504
|
+
beaconFailedAt: 0,
|
|
15505
|
+
beaconAt: 0,
|
|
15506
|
+
leaving: 0,
|
|
15507
|
+
stepDownAt: 0,
|
|
14772
15508
|
lastBeaconWrite: 0,
|
|
14773
15509
|
lastBeaconTry: 0,
|
|
14774
15510
|
hubCandidateAt: 0,
|
|
@@ -14786,6 +15522,9 @@ var Communities = class {
|
|
|
14786
15522
|
lingering: /* @__PURE__ */ new Map(),
|
|
14787
15523
|
seenHubs: /* @__PURE__ */ new Map(),
|
|
14788
15524
|
restored: /* @__PURE__ */ new Map(),
|
|
15525
|
+
farewells: /* @__PURE__ */ new Map(),
|
|
15526
|
+
farewellQuiet: /* @__PURE__ */ new Map(),
|
|
15527
|
+
farewellSaid: /* @__PURE__ */ new Map(),
|
|
14789
15528
|
knocksScanned: false,
|
|
14790
15529
|
knockCursor: 0,
|
|
14791
15530
|
lastShardPoll: 0,
|
|
@@ -14814,22 +15553,17 @@ var Communities = class {
|
|
|
14814
15553
|
if (statusChanged && s.status === "removed") await this.event(groupId, "removed", s.state.statusReason ?? "You were removed from this group", when, s.epoch);
|
|
14815
15554
|
else if (statusChanged && s.status === "forked") await this.event(groupId, "forked", s.state.statusReason ?? "The membership history forked", when, s.epoch);
|
|
14816
15555
|
else if (s.status === "active" && before !== after) {
|
|
14817
|
-
|
|
14818
|
-
for (const [key] of
|
|
14819
|
-
|
|
14820
|
-
if (top.k === "
|
|
15556
|
+
const at = receivedTimestamp(top.ts, when);
|
|
15557
|
+
for (const [key] of after) if (!rosterHas(before, key) && key !== s.myKey) await this.event(groupId, "joined", `${name(key)} joined`, at, top.e, key);
|
|
15558
|
+
for (const [key] of before) if (!rosterHas(after, key)) await this.event(groupId, "gone", `${name(key)} is no longer a member`, at, top.e, key);
|
|
15559
|
+
if (top.k === "role" && before.find(([k]) => k === top.s)?.[1] !== "admin") await this.event(groupId, "admin", `${name(top.s)} ${top.s === s.myKey ? "are" : "is"} now the admin`, at, top.e, top.s);
|
|
15560
|
+
if (top.k === "rotate") await this.event(groupId, "rotated", "Keys rotated: a fresh epoch", at, top.e);
|
|
14821
15561
|
}
|
|
14822
15562
|
this.host.emit();
|
|
14823
15563
|
}
|
|
14824
|
-
/**
|
|
14825
|
-
* A line in the group's history. Its id carries its time, and the engine's clock moves once a tick: two
|
|
14826
|
-
* lines of one kind within a tick (a picture changed, then removed) get distinct times, a millisecond
|
|
14827
|
-
* apart, or the store would keep the first and drop the second as already there.
|
|
14828
|
-
*/
|
|
15564
|
+
/** A line in the group's history. Its id carries its time: two lines at the same moment get distinct ones (`eventTime`). */
|
|
14829
15565
|
async event(groupId, event, text, timestamp, epoch, member) {
|
|
14830
|
-
|
|
14831
|
-
if (timestamp <= last) timestamp = last + 1;
|
|
14832
|
-
this.lastEventAt.set(groupId, Math.max(last, timestamp));
|
|
15566
|
+
timestamp = eventTime(this.eventTimes, groupId, timestamp);
|
|
14833
15567
|
await this.host.storeMessage({
|
|
14834
15568
|
linkId: MESSAGE_LINK$1(groupId),
|
|
14835
15569
|
id: `event:${epoch}:${event}:${member ?? ""}:${timestamp}`,
|
|
@@ -14841,7 +15575,7 @@ var Communities = class {
|
|
|
14841
15575
|
via: "datalink"
|
|
14842
15576
|
});
|
|
14843
15577
|
}
|
|
14844
|
-
|
|
15578
|
+
eventTimes = /* @__PURE__ */ new Map();
|
|
14845
15579
|
};
|
|
14846
15580
|
//#endregion
|
|
14847
15581
|
//#region ../browser/src/engine/meshHubs.ts
|
|
@@ -15256,7 +15990,7 @@ var MeshHubs = class {
|
|
|
15256
15990
|
...legacy.length ? { l: legacy } : {}
|
|
15257
15991
|
};
|
|
15258
15992
|
for (const [key, id] of edges) {
|
|
15259
|
-
if (only && key !== only || !this.host.linkReady(id)) continue;
|
|
15993
|
+
if (only && key !== only || !rosterHas(session.roster, key) || !this.host.linkReady(id)) continue;
|
|
15260
15994
|
try {
|
|
15261
15995
|
this.host.sendOnLink(id, frame);
|
|
15262
15996
|
} catch {}
|
|
@@ -15350,8 +16084,9 @@ var MeshHubs = class {
|
|
|
15350
16084
|
for (const [key, id] of this.host.edges(groupId)) {
|
|
15351
16085
|
if (key === from || !this.host.linkReady(id)) continue;
|
|
15352
16086
|
const member = rosterHas(session.roster, key);
|
|
16087
|
+
const until = member ? Infinity : removalEpoch(session.state.chain, key);
|
|
15353
16088
|
for (const frame of frames) {
|
|
15354
|
-
if (authorOf(frame) === key || !member && frame.t !== "group-commit") continue;
|
|
16089
|
+
if (authorOf(frame) === key || !member && (frame.t !== "group-commit" || frame.commit.e > until)) continue;
|
|
15355
16090
|
try {
|
|
15356
16091
|
this.host.sendOnLink(id, frame);
|
|
15357
16092
|
sent++;
|
|
@@ -15363,6 +16098,14 @@ var MeshHubs = class {
|
|
|
15363
16098
|
return sent;
|
|
15364
16099
|
}
|
|
15365
16100
|
};
|
|
16101
|
+
/**
|
|
16102
|
+
* The epoch of the commit that took `key` out of the group for the last time: the one after the last roster it was
|
|
16103
|
+
* in. -1 for a key the chain never had. Someone out of the roster is sent no commit past it.
|
|
16104
|
+
*/
|
|
16105
|
+
function removalEpoch(chain, key) {
|
|
16106
|
+
for (let e = chain.length - 1; e >= 0; e--) if (rosterHas(chain[e].m, key)) return e + 1;
|
|
16107
|
+
return -1;
|
|
16108
|
+
}
|
|
15366
16109
|
function authorOf(frame) {
|
|
15367
16110
|
switch (frame.t) {
|
|
15368
16111
|
case "group-msg":
|
|
@@ -15450,7 +16193,7 @@ var GroupTypings = class {
|
|
|
15450
16193
|
* knocking, and stayed "invited" for 10 minutes (2026-09-30).
|
|
15451
16194
|
*/
|
|
15452
16195
|
function otherEndSeen(presence, dataLink, now = Date.now()) {
|
|
15453
|
-
return !!dataLink && dataLink !== "idle" || !!presence?.online && now - presence
|
|
16196
|
+
return !!dataLink && dataLink !== "idle" || !!presence?.online && now - presenceSeenAt(presence) < 3e4;
|
|
15454
16197
|
}
|
|
15455
16198
|
/** The key an edit of message `id` goes by in `FramesTaken`. */
|
|
15456
16199
|
var editKey = (id, e) => `${id}#e${e}`;
|
|
@@ -15481,12 +16224,53 @@ var mentionAt = (map, groupId) => {
|
|
|
15481
16224
|
return at ? { lastMentionAt: at } : {};
|
|
15482
16225
|
};
|
|
15483
16226
|
/**
|
|
15484
|
-
* When a
|
|
15485
|
-
*
|
|
15486
|
-
*
|
|
15487
|
-
*
|
|
16227
|
+
* When a message counts as having come, for the list of groups, unread and the "@": when it reached me, never when
|
|
16228
|
+
* its sender says it was written (a member's clock ahead would keep the group unread and on top; written before I
|
|
16229
|
+
* last looked but handed to me since, a catch-up is news here). A copy I had already (`stored` false) or one
|
|
16230
|
+
* completed in place moves nothing forward: its own time, now at the latest.
|
|
16231
|
+
*/
|
|
16232
|
+
var cameAt = (timestamp, stored, now) => stored === false ? Math.min(timestamp, now) : now;
|
|
16233
|
+
/**
|
|
16234
|
+
* The time a line of a group's history is kept under: its own, or the next millisecond no other line of this run took.
|
|
16235
|
+
* A line's id carries its time, and two lines can share a moment (two members gone in one change, a picture changed
|
|
16236
|
+
* then removed within a tick): the store would keep the first and drop the second as already there. Only a time
|
|
16237
|
+
* already taken moves: a line older than the last one written (a rename read after a later commit, in a catch-up)
|
|
16238
|
+
* keeps its place.
|
|
16239
|
+
*/
|
|
16240
|
+
function eventTime(taken, groupId, timestamp) {
|
|
16241
|
+
let times = taken.get(groupId);
|
|
16242
|
+
if (!times) taken.set(groupId, times = /* @__PURE__ */ new Set());
|
|
16243
|
+
while (times.has(timestamp)) timestamp++;
|
|
16244
|
+
times.add(timestamp);
|
|
16245
|
+
if (times.size > 1024) times.delete(times.values().next().value);
|
|
16246
|
+
return timestamp;
|
|
16247
|
+
}
|
|
16248
|
+
/**
|
|
16249
|
+
* The moment something arrives, for the unread marks: this device's clock, never behind it. The engine's own clock is
|
|
16250
|
+
* its last tick's time, up to a second old, while the page marks a group read by the device's clock as it is looked at:
|
|
16251
|
+
* a message that came within that second of leaving the group counted as come before it was read, and the list showed
|
|
16252
|
+
* nothing new. (A simulation's clock runs ahead of the device's: it stands.)
|
|
16253
|
+
*/
|
|
16254
|
+
var arrivalNow = (tick) => Math.max(tick, Date.now());
|
|
16255
|
+
/**
|
|
16256
|
+
* Keeps on the group's record when a member's message came, where that is later than what it holds: the record is
|
|
16257
|
+
* saved with the session right after (a message taken moves its state), so a message handed to me late still makes
|
|
16258
|
+
* the group unread after the app starts again. Read from the history alone, by its own time, it sorted among what I
|
|
16259
|
+
* had read and the mark was gone on the next start.
|
|
15488
16260
|
*/
|
|
15489
|
-
|
|
16261
|
+
function noteCame(group, came, peer, mentioned) {
|
|
16262
|
+
if (!group) return;
|
|
16263
|
+
if (peer && came > (group.came?.peer ?? 0)) group.came = {
|
|
16264
|
+
...group.came,
|
|
16265
|
+
peer: came
|
|
16266
|
+
};
|
|
16267
|
+
if (mentioned && came > (group.came?.mention ?? 0)) group.came = {
|
|
16268
|
+
...group.came,
|
|
16269
|
+
mention: came
|
|
16270
|
+
};
|
|
16271
|
+
}
|
|
16272
|
+
/** What `load` starts the unread marks from: the history's own times, or when its latest came if that was later. */
|
|
16273
|
+
var cameOrWritten = (written, came) => written === void 0 && came === void 0 ? void 0 : Math.max(written ?? 0, came ?? 0);
|
|
15490
16274
|
/** The view's `lastPeerMessageAt`, when there is one. */
|
|
15491
16275
|
var peerMessageAt = (map, groupId) => {
|
|
15492
16276
|
const at = map.get(groupId);
|
|
@@ -15500,6 +16284,7 @@ var ENTRY_TIMINGS = {
|
|
|
15500
16284
|
slowKnockMs: 2e4,
|
|
15501
16285
|
patienceMs: 12e4
|
|
15502
16286
|
};
|
|
16287
|
+
var KNOCK_VERIFY_SPREAD_MS = 700;
|
|
15503
16288
|
/** Entry sessions the admin runs at once; a joiner who does not finish in time is not answered again for a while. */
|
|
15504
16289
|
var MAX_PENDING_ENTRIES = 4;
|
|
15505
16290
|
var ENTRY_TIMEOUT_MS = 18e4;
|
|
@@ -15510,12 +16295,19 @@ var REFUSED_FOR_MS = 6e5;
|
|
|
15510
16295
|
* doubling with each timeout in a row up to `REFUSED_FOR_MS`: a slow network is no refusal.
|
|
15511
16296
|
*/
|
|
15512
16297
|
var ENTRY_RETRY_MS = 3e4;
|
|
15513
|
-
/**
|
|
16298
|
+
/**
|
|
16299
|
+
* The welcome is on its way when the admin sends it; the session stays up a little for it to arrive. A joiner keeps
|
|
16300
|
+
* its side as long (unless its edge to the admin is up first): the admin says over it what it commits meanwhile.
|
|
16301
|
+
*/
|
|
15514
16302
|
var ENTRY_LINGER_MS = 2e4;
|
|
15515
16303
|
/** Taken out of a group, my edges stay this long: as a hub, the commit that removed me is passed on over them. */
|
|
15516
16304
|
var REMOVED_LINGER_MS = 15e3;
|
|
15517
16305
|
/** How long the tombstone of a group I left waits for the admin to hear it. */
|
|
15518
16306
|
var LEFT_KEPT_MS = 6048e5;
|
|
16307
|
+
/** Syncs of a removed member answered with the commits it lacks, per farewell: it needs one. */
|
|
16308
|
+
var FAREWELL_ANSWERS = 3;
|
|
16309
|
+
/** Commits sent to a removed member that was behind when it left, at most. */
|
|
16310
|
+
var FAREWELL_COMMITS = 64;
|
|
15519
16311
|
/** Where a member's gossip turns start among the members it is connected to: a number of its own, from its key. */
|
|
15520
16312
|
var gossipStart = (key) => {
|
|
15521
16313
|
let n = 0;
|
|
@@ -15594,8 +16386,21 @@ var Groups = class {
|
|
|
15594
16386
|
/** Admin side: until when a group's link is looked at the warm pace. */
|
|
15595
16387
|
warmUntil = /* @__PURE__ */ new Map();
|
|
15596
16388
|
lastKnock = /* @__PURE__ */ new Map();
|
|
16389
|
+
/** Joiner side: when a knock just written is read back (`KNOCK_VERIFY_MS`); none once it was found there. */
|
|
16390
|
+
knockCheckAt = /* @__PURE__ */ new Map();
|
|
16391
|
+
/** Joiner side: knocks written in a row without one found in the record since (`KNOCK_VERIFIES`). */
|
|
16392
|
+
knockRewrites = /* @__PURE__ */ new Map();
|
|
15597
16393
|
/** Per group, members met over their admission a moment ago: their edge is opened expecting them. */
|
|
15598
16394
|
justMet = /* @__PURE__ */ new Map();
|
|
16395
|
+
/**
|
|
16396
|
+
* Admin side: joiners let in through the link whose entry session is still open, per group: member key → link id.
|
|
16397
|
+
* Until the edge to one of them is up, a commit made meanwhile (the next joiner's admission) goes to it over that
|
|
16398
|
+
* session: it would otherwise seal what it writes for a roster that is no longer the group's, and whoever was let
|
|
16399
|
+
* in since could never read it (two people open the link together, both say hello, one never sees the other's).
|
|
16400
|
+
*/
|
|
16401
|
+
welcomed = /* @__PURE__ */ new Map();
|
|
16402
|
+
/** Joiner side: the entry session I was let in through, kept until my edge to the admin is up or `until`. */
|
|
16403
|
+
lingering = /* @__PURE__ */ new Map();
|
|
15599
16404
|
/** Joiner side: groups whose knock is published, for the stage the joiner is shown. */
|
|
15600
16405
|
knocked = /* @__PURE__ */ new Set();
|
|
15601
16406
|
/**
|
|
@@ -15651,9 +16456,11 @@ var Groups = class {
|
|
|
15651
16456
|
const history = await this.store.getMessages(MESSAGE_LINK(group.id)), said = history.filter((m) => !m.event && !m.groupPay);
|
|
15652
16457
|
const last = said[said.length - 1], lastPeer = [...said].reverse().find((m) => m.sender !== "me");
|
|
15653
16458
|
if (last) this.lastMessageAt.set(group.id, last.timestamp);
|
|
15654
|
-
|
|
16459
|
+
const peerAt = cameOrWritten(lastPeer?.timestamp, lastPeer ? group.came?.peer : void 0);
|
|
16460
|
+
if (peerAt) this.lastPeerMessageAt.set(group.id, peerAt);
|
|
15655
16461
|
const mention = [...history].reverse().find((m) => m.mentioned);
|
|
15656
|
-
|
|
16462
|
+
const mentionAt = cameOrWritten(mention?.timestamp, mention ? group.came?.mention : void 0);
|
|
16463
|
+
if (mentionAt) this.lastMentionAt.set(group.id, mentionAt);
|
|
15657
16464
|
}
|
|
15658
16465
|
for (const id of this.sessions.keys()) this.reconcileEdges(id);
|
|
15659
16466
|
for (const group of this.stored.values()) for (const [, linkId] of this.host.entries(group.id)) if (!group.invitation?.entry || group.invitation.linkId !== linkId) await this.host.closeEdge(linkId);
|
|
@@ -15957,6 +16764,8 @@ var Groups = class {
|
|
|
15957
16764
|
delete group.entry;
|
|
15958
16765
|
this.invited.delete(groupId);
|
|
15959
16766
|
this.pendingEntries.delete(groupId);
|
|
16767
|
+
this.welcomed.delete(groupId);
|
|
16768
|
+
this.lingering.delete(groupId);
|
|
15960
16769
|
this.lastMessageAt.delete(groupId);
|
|
15961
16770
|
this.lastPeerMessageAt.delete(groupId);
|
|
15962
16771
|
this.lastMentionAt.delete(groupId);
|
|
@@ -15989,25 +16798,138 @@ var Groups = class {
|
|
|
15989
16798
|
}
|
|
15990
16799
|
async remove(groupId, key) {
|
|
15991
16800
|
if (this.isCommunity(groupId)) return this.communities.remove(groupId, key);
|
|
15992
|
-
|
|
15993
|
-
const
|
|
16801
|
+
const session = this.session(groupId), group = this.stored.get(groupId);
|
|
16802
|
+
const edge = this.host.edges(groupId).get(key), edgeUp = !!edge && this.host.linkReady(edge);
|
|
16803
|
+
const noted = !edgeUp && session.isAdmin && rosterHas(session.roster, key) && key !== session.myKey;
|
|
16804
|
+
if (noted) {
|
|
16805
|
+
this.noteFarewell(group, key, session.epoch + 1);
|
|
16806
|
+
if (edge && this.host.linkOpen?.(edge)) group.farewells[key].opened = this.now();
|
|
16807
|
+
}
|
|
16808
|
+
try {
|
|
16809
|
+
await session.remove(key);
|
|
16810
|
+
} catch (error) {
|
|
16811
|
+
if (noted) this.dropFarewell(group, key);
|
|
16812
|
+
throw error;
|
|
16813
|
+
}
|
|
16814
|
+
const contact = group.contacts?.[key];
|
|
16815
|
+
let told = edgeUp;
|
|
15994
16816
|
if (contact) try {
|
|
15995
16817
|
this.host.sendOnLink(contact, {
|
|
15996
16818
|
t: "group-removed",
|
|
15997
16819
|
g: groupId
|
|
15998
16820
|
});
|
|
16821
|
+
told = true;
|
|
15999
16822
|
} catch {}
|
|
16823
|
+
if (noted && told) this.dropFarewell(group, key);
|
|
16824
|
+
if (noted) {
|
|
16825
|
+
await this.store.putGroup(group);
|
|
16826
|
+
this.reconcileEdges(groupId);
|
|
16827
|
+
}
|
|
16000
16828
|
}
|
|
16001
|
-
|
|
16002
|
-
|
|
16829
|
+
/** A member removed while it cannot be told: its edge stays until it opens (see `StoredGroup.farewells`). The oldest go past `MAX_FAREWELLS`. */
|
|
16830
|
+
noteFarewell(group, key, e) {
|
|
16831
|
+
const kept = Object.entries(group.farewells ?? {}).filter(([k]) => k !== key).sort(([, a], [, b]) => b.at - a.at).slice(0, 7);
|
|
16832
|
+
group.farewells = Object.fromEntries([...kept, [key, {
|
|
16833
|
+
at: this.now(),
|
|
16834
|
+
e
|
|
16835
|
+
}]]);
|
|
16003
16836
|
}
|
|
16004
|
-
|
|
16005
|
-
|
|
16006
|
-
|
|
16007
|
-
|
|
16008
|
-
|
|
16009
|
-
|
|
16010
|
-
|
|
16837
|
+
dropFarewell(group, key) {
|
|
16838
|
+
if (!group.farewells?.[key]) return;
|
|
16839
|
+
const { [key]: _gone, ...rest } = group.farewells;
|
|
16840
|
+
group.farewells = Object.keys(rest).length ? rest : void 0;
|
|
16841
|
+
}
|
|
16842
|
+
/** The removed members this admin still waits for: not told yet, or told a moment ago (the commit is on its way). */
|
|
16843
|
+
farewellKeys(group, session, now) {
|
|
16844
|
+
if (!group?.farewells || session.status !== "active") return [];
|
|
16845
|
+
return Object.entries(group.farewells).filter(([key, f]) => !rosterHas(session.roster, key) && now - f.at <= 6048e5 && (f.told === void 0 || now - f.told < REMOVED_LINGER_MS) && (f.opened === void 0 || now - f.opened < 6e4)).map(([key]) => key);
|
|
16846
|
+
}
|
|
16847
|
+
/** The edges an active member keeps: what the roster (or the hubs) ask for, and those to members it removed while they were away. */
|
|
16848
|
+
edgesWanted(groupId, session, group, now) {
|
|
16849
|
+
const wanted = this.hubs.edgesWanted(groupId, session, group, now), farewells = this.farewellKeys(group, session, now);
|
|
16850
|
+
return farewells.length ? /* @__PURE__ */ new Set([...wanted, ...farewells]) : wanted;
|
|
16851
|
+
}
|
|
16852
|
+
/** A member key in the roster of a private group I am active in (a community: its own engine answers). */
|
|
16853
|
+
inRoster(groupId, key) {
|
|
16854
|
+
const session = this.sessions.get(groupId);
|
|
16855
|
+
return !!session && session.status === "active" && rosterHas(session.roster, key);
|
|
16856
|
+
}
|
|
16857
|
+
/** A key out of the roster of a private group I am active in: a former member, or a stranger. */
|
|
16858
|
+
outsider(groupId, key) {
|
|
16859
|
+
const session = this.sessions.get(groupId);
|
|
16860
|
+
return !!session && session.status === "active" && !rosterHas(session.roster, key);
|
|
16861
|
+
}
|
|
16862
|
+
/**
|
|
16863
|
+
* Whether a frame may go over a private group's edge to `key`. To a member, anything. To someone out of the roster
|
|
16864
|
+
* of a group I am active in, only the commits up to the one that took it out: an edge to a former member exists a
|
|
16865
|
+
* moment (its last commit is on its way) or is kept to tell it (`StoredGroup.farewells`), and carries nothing else.
|
|
16866
|
+
*/
|
|
16867
|
+
edgeAllows(groupId, key, frame) {
|
|
16868
|
+
const session = this.sessions.get(groupId);
|
|
16869
|
+
if (!session || session.status !== "active" || rosterHas(session.roster, key)) return true;
|
|
16870
|
+
const commit = frame && typeof frame === "object" && frame.t === "group-commit" ? frame.commit : void 0;
|
|
16871
|
+
return typeof commit?.e === "number" && commit.e <= removalEpoch(session.state.chain, key);
|
|
16872
|
+
}
|
|
16873
|
+
/** The connection of an edge opened. One kept to tell a removed member closes a while after, whatever its app says or does not say. */
|
|
16874
|
+
edgeOpen(groupId, peerKey) {
|
|
16875
|
+
const group = this.stored.get(groupId), farewell = group?.farewells?.[peerKey];
|
|
16876
|
+
if (!group || !farewell || farewell.opened !== void 0) return;
|
|
16877
|
+
farewell.opened = this.now();
|
|
16878
|
+
this.store.putGroup(group).catch(() => {});
|
|
16879
|
+
}
|
|
16880
|
+
/**
|
|
16881
|
+
* The edge to a member I removed while it was away came up: the commit that removed it goes now, which is all this
|
|
16882
|
+
* edge carries. Its app applies it when it had followed the chain to the commit before; one further behind says
|
|
16883
|
+
* where it is in its sync, and gets the commits between (`farewellSync`). No secret goes with any of them.
|
|
16884
|
+
*/
|
|
16885
|
+
sayFarewell(groupId, session, group, key, linkId) {
|
|
16886
|
+
const farewell = group.farewells[key], commit = session.state.chain[farewell.e];
|
|
16887
|
+
if (!commit) {
|
|
16888
|
+
this.dropFarewell(group, key);
|
|
16889
|
+
return;
|
|
16890
|
+
}
|
|
16891
|
+
try {
|
|
16892
|
+
this.host.sendOnLink(linkId, {
|
|
16893
|
+
t: "group-commit",
|
|
16894
|
+
g: groupId,
|
|
16895
|
+
commit
|
|
16896
|
+
});
|
|
16897
|
+
} catch {
|
|
16898
|
+
return;
|
|
16899
|
+
}
|
|
16900
|
+
farewell.told ??= this.now();
|
|
16901
|
+
this.store.putGroup(group).catch(() => {});
|
|
16902
|
+
}
|
|
16903
|
+
/** The sync of a removed member I wait for: the commits it lacks up to the one that removed it, a few times at most. */
|
|
16904
|
+
farewellSync(groupId, session, group, key, frame) {
|
|
16905
|
+
const farewell = group.farewells[key], linkId = this.host.edges(groupId).get(key);
|
|
16906
|
+
const from = frame.e;
|
|
16907
|
+
if (!linkId || frame.t !== "group-sync" || !Number.isSafeInteger(from) || from < 0 || from >= farewell.e - 1) return;
|
|
16908
|
+
if ((farewell.answered ?? 0) >= FAREWELL_ANSWERS) return;
|
|
16909
|
+
farewell.answered = (farewell.answered ?? 0) + 1;
|
|
16910
|
+
try {
|
|
16911
|
+
for (let e = Math.max(from + 1, farewell.e - FAREWELL_COMMITS); e <= farewell.e; e++) {
|
|
16912
|
+
const commit = session.state.chain[e];
|
|
16913
|
+
if (commit) this.host.sendOnLink(linkId, {
|
|
16914
|
+
t: "group-commit",
|
|
16915
|
+
g: groupId,
|
|
16916
|
+
commit
|
|
16917
|
+
});
|
|
16918
|
+
}
|
|
16919
|
+
} catch {}
|
|
16920
|
+
farewell.told ??= this.now();
|
|
16921
|
+
this.store.putGroup(group).catch(() => {});
|
|
16922
|
+
}
|
|
16923
|
+
makeAdmin(groupId, key) {
|
|
16924
|
+
return this.isCommunity(groupId) ? this.communities.makeAdmin(groupId, key) : this.session(groupId).transferAdmin(key);
|
|
16925
|
+
}
|
|
16926
|
+
/**
|
|
16927
|
+
* The admin pins a member of a private group as a hub, excludes one from being a hub, or leaves it to the member's
|
|
16928
|
+
* app (`null`): said in the group's metadata, which every member keeps (WISP 9xx · Group Mesh § Hubs).
|
|
16929
|
+
*/
|
|
16930
|
+
async setHub(groupId, key, role) {
|
|
16931
|
+
if (this.isCommunity(groupId)) throw new Error("A community group chooses its hubs by itself");
|
|
16932
|
+
await this.session(groupId).setHub(key, role);
|
|
16011
16933
|
this.host.emit();
|
|
16012
16934
|
}
|
|
16013
16935
|
rotate(groupId) {
|
|
@@ -16035,7 +16957,11 @@ var Groups = class {
|
|
|
16035
16957
|
this.lastTold.delete(groupId);
|
|
16036
16958
|
this.pendingEntries.delete(groupId);
|
|
16037
16959
|
this.knocked.delete(groupId);
|
|
16960
|
+
this.knockCheckAt.delete(groupId);
|
|
16961
|
+
this.knockRewrites.delete(groupId);
|
|
16038
16962
|
this.justMet.delete(groupId);
|
|
16963
|
+
this.welcomed.delete(groupId);
|
|
16964
|
+
this.lingering.delete(groupId);
|
|
16039
16965
|
this.relayAsked.delete(groupId);
|
|
16040
16966
|
this.hubs.forget(groupId);
|
|
16041
16967
|
this.removedAt.delete(groupId);
|
|
@@ -16103,7 +17029,7 @@ var Groups = class {
|
|
|
16103
17029
|
if (existing?.state?.status === "active" || existing?.invitation?.entry === link.host) return link.g;
|
|
16104
17030
|
if (existing?.invitation?.seedB64 && !existing.invitation.entry) throw new Error("You are already joining this group");
|
|
16105
17031
|
if (existing) await this.forget(link.g);
|
|
16106
|
-
const seedB64 =
|
|
17032
|
+
const seedB64 = dialedKey(link);
|
|
16107
17033
|
const linkId = await this.host.openEntry(link, "guest", seedB64, link.host);
|
|
16108
17034
|
const group = {
|
|
16109
17035
|
id: link.g,
|
|
@@ -16138,7 +17064,7 @@ var Groups = class {
|
|
|
16138
17064
|
}
|
|
16139
17065
|
if (group.invitation?.entry && !group.state) {
|
|
16140
17066
|
const every = now - group.createdAt > this.timings.patienceMs ? this.timings.slowKnockMs : this.timings.knockMs;
|
|
16141
|
-
if (!(this.host.linkReady(group.invitation.linkId) || !!this.host.linkSeen?.(group.invitation.linkId)) && now - (this.lastKnock.get(group.id) ?? 0) >= every) await this.knock(group, now).catch(() => {});
|
|
17067
|
+
if (!(this.host.linkReady(group.invitation.linkId) || !!this.host.linkSeen?.(group.invitation.linkId)) && (now - (this.lastKnock.get(group.id) ?? 0) >= every || now >= (this.knockCheckAt.get(group.id) ?? Infinity))) await this.knock(group, now).catch(() => {});
|
|
16142
17068
|
continue;
|
|
16143
17069
|
}
|
|
16144
17070
|
const session = this.sessions.get(group.id);
|
|
@@ -16167,6 +17093,7 @@ var Groups = class {
|
|
|
16167
17093
|
}
|
|
16168
17094
|
for (const [key, until] of this.refused) if (until <= now) this.refused.delete(key);
|
|
16169
17095
|
for (const [key, { at }] of this.entryTimeouts) if (now - at > 36e5) this.entryTimeouts.delete(key);
|
|
17096
|
+
for (const [groupId, kept] of this.lingering) if (now >= kept.until) await this.endLinger(groupId);
|
|
16170
17097
|
this.meshTick(now);
|
|
16171
17098
|
await this.communities.tick(now);
|
|
16172
17099
|
await this.hubsTick(now);
|
|
@@ -16188,7 +17115,7 @@ var Groups = class {
|
|
|
16188
17115
|
if (!group || group.left || session.status !== "active" || !this.hubs.large(session)) continue;
|
|
16189
17116
|
await this.hubs.tick(groupId, session, group, now).catch(() => false);
|
|
16190
17117
|
if (this.hubReconcile.has(groupId)) continue;
|
|
16191
|
-
const wanted = this.
|
|
17118
|
+
const wanted = this.edgesWanted(groupId, session, group, now);
|
|
16192
17119
|
const edges = this.host.edges(groupId);
|
|
16193
17120
|
if ([...wanted].some((key) => !edges.has(key)) || [...edges.keys()].some((key) => !wanted.has(key))) {
|
|
16194
17121
|
this.hubReconcile.add(groupId);
|
|
@@ -16235,8 +17162,16 @@ var Groups = class {
|
|
|
16235
17162
|
seedB64: group.entry.seedB64
|
|
16236
17163
|
} : void 0;
|
|
16237
17164
|
}
|
|
17165
|
+
/** Joiner side: the entry session kept after the welcome goes (the edge to the admin is up, or it was kept long enough). */
|
|
17166
|
+
async endLinger(groupId) {
|
|
17167
|
+
const kept = this.lingering.get(groupId);
|
|
17168
|
+
if (!kept) return;
|
|
17169
|
+
this.lingering.delete(groupId);
|
|
17170
|
+
if ([...this.host.entries(groupId).values()].includes(kept.linkId)) await this.host.closeEdge(kept.linkId);
|
|
17171
|
+
}
|
|
16238
17172
|
async closeEntries(groupId) {
|
|
16239
17173
|
this.pendingEntries.delete(groupId);
|
|
17174
|
+
this.welcomed.delete(groupId);
|
|
16240
17175
|
const invited = this.invited.get(groupId);
|
|
16241
17176
|
for (const linkId of this.host.entries(groupId).values()) {
|
|
16242
17177
|
invited?.delete(linkId);
|
|
@@ -16251,15 +17186,29 @@ var Groups = class {
|
|
|
16251
17186
|
host: invitation.entry
|
|
16252
17187
|
}, identity = knockIdentity(link);
|
|
16253
17188
|
const started = Date.now();
|
|
17189
|
+
this.knockCheckAt.delete(group.id);
|
|
16254
17190
|
const existing = readKnocks(link, await this.host.resolve(identity.pubKeyZ32) ?? []);
|
|
17191
|
+
const key = identityFromSeedB64(invitation.seedB64).pubKeyZ32, mine = existing.find((k) => k.key === key);
|
|
16255
17192
|
traceJoin(group.id, "knock.read", {
|
|
16256
17193
|
ms: Date.now() - started,
|
|
16257
|
-
others: existing.length
|
|
17194
|
+
others: existing.length,
|
|
17195
|
+
...mine ? { mine: now - mine.ts } : {}
|
|
16258
17196
|
});
|
|
17197
|
+
if (mine && now - mine.ts >= 0 && now - mine.ts < 3e4) {
|
|
17198
|
+
this.knockRewrites.delete(group.id);
|
|
17199
|
+
if (!this.knocked.has(group.id)) {
|
|
17200
|
+
this.knocked.add(group.id);
|
|
17201
|
+
this.host.emit();
|
|
17202
|
+
}
|
|
17203
|
+
return;
|
|
17204
|
+
}
|
|
16259
17205
|
await this.host.publish(identity, knockRecords(link, mergeKnocks(existing, {
|
|
16260
|
-
key
|
|
17206
|
+
key,
|
|
16261
17207
|
ts: now
|
|
16262
17208
|
}, now)));
|
|
17209
|
+
const rewrites = (this.knockRewrites.get(group.id) ?? 0) + 1;
|
|
17210
|
+
this.knockRewrites.set(group.id, rewrites);
|
|
17211
|
+
if (rewrites <= 2) this.knockCheckAt.set(group.id, now + 800 + gossipStart(key) % KNOCK_VERIFY_SPREAD_MS);
|
|
16263
17212
|
traceJoin(group.id, "knock.published", { ms: Date.now() - started });
|
|
16264
17213
|
if (!this.knocked.has(group.id)) {
|
|
16265
17214
|
this.knocked.add(group.id);
|
|
@@ -16375,7 +17324,11 @@ var Groups = class {
|
|
|
16375
17324
|
this.entryTimeouts.delete(entryPeer);
|
|
16376
17325
|
this.pendingEntries.get(g)?.delete(entryPeer);
|
|
16377
17326
|
this.host.entryDone?.(linkId);
|
|
17327
|
+
let welcomed = this.welcomed.get(g);
|
|
17328
|
+
if (!welcomed) this.welcomed.set(g, welcomed = /* @__PURE__ */ new Map());
|
|
17329
|
+
welcomed.set(entryPeer, linkId);
|
|
16378
17330
|
setTimeout(() => {
|
|
17331
|
+
if (this.welcomed.get(g)?.get(entryPeer) === linkId) this.welcomed.get(g).delete(entryPeer);
|
|
16379
17332
|
if (this.host.entries(g).get(entryPeer) === linkId) this.host.closeEdge(linkId);
|
|
16380
17333
|
}, ENTRY_LINGER_MS);
|
|
16381
17334
|
}
|
|
@@ -16423,6 +17376,11 @@ var Groups = class {
|
|
|
16423
17376
|
};
|
|
16424
17377
|
this.stored.set(g, member);
|
|
16425
17378
|
this.attach(joined.state);
|
|
17379
|
+
if (viaLink) this.lingering.set(g, {
|
|
17380
|
+
linkId,
|
|
17381
|
+
admin: group.invitation.admin,
|
|
17382
|
+
until: this.now() + ENTRY_LINGER_MS
|
|
17383
|
+
});
|
|
16426
17384
|
await this.store.putGroup(member);
|
|
16427
17385
|
if (!viaLink) await this.sessions.get(g).setNick(group.invitation.admin, this.host.contactName(linkId));
|
|
16428
17386
|
await this.event(g, "joined", `You joined. ${GROUP_READ_NOTE}`, Date.now(), joined.state.chain.length - 1);
|
|
@@ -16430,12 +17388,18 @@ var Groups = class {
|
|
|
16430
17388
|
if (viaLink) {
|
|
16431
17389
|
this.lastKnock.delete(g);
|
|
16432
17390
|
this.knocked.delete(g);
|
|
16433
|
-
await this.host.closeEdge(linkId);
|
|
16434
17391
|
}
|
|
16435
17392
|
this.reconcileEdges(g);
|
|
16436
17393
|
this.host.emit();
|
|
16437
17394
|
return;
|
|
16438
17395
|
}
|
|
17396
|
+
case "group-commit": {
|
|
17397
|
+
const kept = this.lingering.get(g), session = this.sessions.get(g);
|
|
17398
|
+
if (!kept || kept.linkId !== linkId || !session) return;
|
|
17399
|
+
const taken = await session.handle(kept.admin, frame);
|
|
17400
|
+
if (taken.length) this.hubs.passOn(g, session, kept.admin, taken);
|
|
17401
|
+
return;
|
|
17402
|
+
}
|
|
16439
17403
|
case "group-removed": {
|
|
16440
17404
|
const session = this.sessions.get(g);
|
|
16441
17405
|
const group = this.stored.get(g);
|
|
@@ -16463,6 +17427,10 @@ var Groups = class {
|
|
|
16463
17427
|
}
|
|
16464
17428
|
}
|
|
16465
17429
|
}
|
|
17430
|
+
/** A key the group's chain had as a member and has no more (not one it never had: a member my chain has not reached yet). */
|
|
17431
|
+
tookOut(session, key) {
|
|
17432
|
+
return !rosterHas(session.roster, key) && removalEpoch(session.state.chain, key) >= 0;
|
|
17433
|
+
}
|
|
16466
17434
|
/** A `group-*` frame on an edge: from the member the edge is pinned to. */
|
|
16467
17435
|
async handleEdgeFrame(groupId, peerKey, frame) {
|
|
16468
17436
|
if (this.isCommunity(groupId)) return this.communities.handleEdgeFrame(groupId, peerKey, frame);
|
|
@@ -16471,6 +17439,12 @@ var Groups = class {
|
|
|
16471
17439
|
return;
|
|
16472
17440
|
}
|
|
16473
17441
|
const session = this.sessions.get(groupId);
|
|
17442
|
+
const waiting = this.stored.get(groupId);
|
|
17443
|
+
if (session && waiting?.farewells?.[peerKey] && !rosterHas(session.roster, peerKey)) {
|
|
17444
|
+
if (session.status === "active" && frame && typeof frame === "object") this.farewellSync(groupId, session, waiting, peerKey, frame);
|
|
17445
|
+
return;
|
|
17446
|
+
}
|
|
17447
|
+
if (session?.status === "active" && this.tookOut(session, peerKey)) return;
|
|
16474
17448
|
if (session?.status === "active" && frame && typeof frame === "object" && frame.t === "group-here") {
|
|
16475
17449
|
this.heardHere(groupId, session, peerKey, frame);
|
|
16476
17450
|
return;
|
|
@@ -16559,6 +17533,10 @@ var Groups = class {
|
|
|
16559
17533
|
return;
|
|
16560
17534
|
}
|
|
16561
17535
|
const session = this.sessions.get(groupId), group = this.stored.get(groupId);
|
|
17536
|
+
if (session?.status === "active" && group?.farewells?.[peerKey] && !rosterHas(session.roster, peerKey)) {
|
|
17537
|
+
this.sayFarewell(groupId, session, group, peerKey, linkId);
|
|
17538
|
+
return;
|
|
17539
|
+
}
|
|
16562
17540
|
if (!session || !group || session.status !== "active" || !rosterHas(session.roster, peerKey)) return;
|
|
16563
17541
|
const large = this.host.linkReady(linkId, 3) && this.markLarge(group, peerKey);
|
|
16564
17542
|
const legacy = this.hubs.enabled && this.hubs.edgeReady(groupId, session, group, peerKey, this.host.linkReady(linkId, 4), this.now());
|
|
@@ -16568,6 +17546,8 @@ var Groups = class {
|
|
|
16568
17546
|
} catch {
|
|
16569
17547
|
return;
|
|
16570
17548
|
}
|
|
17549
|
+
if (this.lingering.get(groupId)?.admin === peerKey) this.endLinger(groupId).catch(() => {});
|
|
17550
|
+
this.welcomed.get(groupId)?.delete(peerKey);
|
|
16571
17551
|
this.announceHere(groupId, session, peerKey);
|
|
16572
17552
|
this.host.edgeUp?.(groupId, peerKey);
|
|
16573
17553
|
this.host.emit();
|
|
@@ -16687,6 +17667,7 @@ var Groups = class {
|
|
|
16687
17667
|
if (reconcile) this.lastReconcile = now;
|
|
16688
17668
|
for (const [groupId, session] of this.sessions) {
|
|
16689
17669
|
if (session.status !== "active" || this.stored.get(groupId)?.left) continue;
|
|
17670
|
+
this.farewellsTick(groupId, session, now);
|
|
16690
17671
|
if (reconcile) {
|
|
16691
17672
|
const edges = this.host.edges(groupId);
|
|
16692
17673
|
if (!this.hubs.large(session) && session.others.some((key) => !edges.has(key))) this.reconcileEdges(groupId);
|
|
@@ -16710,12 +17691,25 @@ var Groups = class {
|
|
|
16710
17691
|
} catch {}
|
|
16711
17692
|
}
|
|
16712
17693
|
}
|
|
17694
|
+
/** A removed member told a moment ago, or waited for a week: its edge goes, and nothing more is kept about it. */
|
|
17695
|
+
farewellsTick(groupId, session, now) {
|
|
17696
|
+
const group = this.stored.get(groupId);
|
|
17697
|
+
if (!group?.farewells) return;
|
|
17698
|
+
const waiting = new Set(this.farewellKeys(group, session, now));
|
|
17699
|
+
const done = Object.keys(group.farewells).filter((key) => !waiting.has(key));
|
|
17700
|
+
if (!done.length) return;
|
|
17701
|
+
for (const key of done) this.dropFarewell(group, key);
|
|
17702
|
+
this.store.putGroup(group).catch(() => {});
|
|
17703
|
+
this.reconcileEdges(groupId);
|
|
17704
|
+
}
|
|
16713
17705
|
edgeNick(groupId, peerKey, nick) {
|
|
16714
17706
|
if (this.isCommunity(groupId)) {
|
|
16715
17707
|
this.communities.edgeNick(groupId, peerKey, nick);
|
|
16716
17708
|
return;
|
|
16717
17709
|
}
|
|
16718
|
-
this.sessions.get(groupId)
|
|
17710
|
+
const session = this.sessions.get(groupId);
|
|
17711
|
+
if (session && this.stored.get(groupId)?.farewells?.[peerKey] && !rosterHas(session.roster, peerKey)) return;
|
|
17712
|
+
session?.setNick(peerKey, nick);
|
|
16719
17713
|
}
|
|
16720
17714
|
session(groupId) {
|
|
16721
17715
|
const session = this.sessions.get(groupId);
|
|
@@ -16733,10 +17727,14 @@ var Groups = class {
|
|
|
16733
17727
|
},
|
|
16734
17728
|
send: (to, frame) => {
|
|
16735
17729
|
const edge = this.host.edges(state.id).get(to);
|
|
16736
|
-
if (!edge) return;
|
|
16737
17730
|
try {
|
|
17731
|
+
if (!edge) throw new Error("no edge");
|
|
16738
17732
|
this.host.sendOnLink(edge, frame);
|
|
16739
17733
|
} catch {
|
|
17734
|
+
const entry = frame.t === "group-commit" ? this.welcomed.get(state.id)?.get(to) : void 0;
|
|
17735
|
+
if (entry) try {
|
|
17736
|
+
this.host.sendOnLink(entry, frame);
|
|
17737
|
+
} catch {}
|
|
16740
17738
|
return;
|
|
16741
17739
|
}
|
|
16742
17740
|
if ((frame.t === "group-msg" || frame.t === "group-edit") && frame.s === session.myKey) {
|
|
@@ -16745,7 +17743,7 @@ var Groups = class {
|
|
|
16745
17743
|
}
|
|
16746
17744
|
},
|
|
16747
17745
|
message: async (m) => {
|
|
16748
|
-
const timestamp =
|
|
17746
|
+
const timestamp = m.timestamp;
|
|
16749
17747
|
const mentioned = m.sender !== session.myKey && mentionsMember(m.mentions, session.myKey);
|
|
16750
17748
|
const message = {
|
|
16751
17749
|
linkId: MESSAGE_LINK(state.id),
|
|
@@ -16761,10 +17759,11 @@ var Groups = class {
|
|
|
16761
17759
|
...m.card && { card: m.card }
|
|
16762
17760
|
};
|
|
16763
17761
|
const stored = m.completes && this.host.completeMessage ? (await this.host.completeMessage(message), false) : await this.host.storeMessage(message);
|
|
16764
|
-
|
|
16765
|
-
|
|
17762
|
+
const came = m.sender === session.myKey ? timestamp : cameAt(timestamp, stored, arrivalNow(this.now()));
|
|
17763
|
+
this.lastMessageAt.set(state.id, Math.max(this.lastMessageAt.get(state.id) ?? 0, came));
|
|
16766
17764
|
if (mentioned) this.lastMentionAt.set(state.id, Math.max(this.lastMentionAt.get(state.id) ?? 0, came));
|
|
16767
17765
|
if (m.sender !== session.myKey) this.lastPeerMessageAt.set(state.id, Math.max(this.lastPeerMessageAt.get(state.id) ?? 0, came));
|
|
17766
|
+
noteCame(this.stored.get(state.id), came, m.sender !== session.myKey, mentioned);
|
|
16768
17767
|
this.typings.messageFrom(state.id, m.sender);
|
|
16769
17768
|
},
|
|
16770
17769
|
edit: (e) => this.host.groupEdit?.(state.id, e),
|
|
@@ -16772,8 +17771,8 @@ var Groups = class {
|
|
|
16772
17771
|
if (session.status === "removed" && this.hubs.isHub(state.id) && !this.removedAt.has(state.id)) this.removedAt.set(state.id, this.now());
|
|
16773
17772
|
this.membershipChanged(state.id);
|
|
16774
17773
|
},
|
|
16775
|
-
metaChanged: (by, change) => {
|
|
16776
|
-
this.metaChanged(state.id, session, by, change);
|
|
17774
|
+
metaChanged: (by, change, at) => {
|
|
17775
|
+
this.metaChanged(state.id, session, by, change, at);
|
|
16777
17776
|
}
|
|
16778
17777
|
});
|
|
16779
17778
|
this.sessions.set(state.id, session);
|
|
@@ -16795,7 +17794,7 @@ var Groups = class {
|
|
|
16795
17794
|
top
|
|
16796
17795
|
});
|
|
16797
17796
|
const name = (key) => key === session.myKey ? "You" : session.state.nicks[key] ?? `Member ${key.slice(0, 8)}`;
|
|
16798
|
-
const when = Date.now();
|
|
17797
|
+
const when = moved ? receivedTimestamp(top.ts) : Date.now();
|
|
16799
17798
|
if (session.status === "removed") {
|
|
16800
17799
|
if (statusChanged) await this.event(groupId, "removed", session.state.statusReason ?? "You were removed from this group", when, session.epoch);
|
|
16801
17800
|
} else if (session.status === "forked") {
|
|
@@ -16817,9 +17816,11 @@ var Groups = class {
|
|
|
16817
17816
|
this.host.membersChanged?.(groupId);
|
|
16818
17817
|
this.host.emit();
|
|
16819
17818
|
}
|
|
16820
|
-
|
|
17819
|
+
/** `at`: when the admin signed the statement (the line's time, as a commit's is its line's). */
|
|
17820
|
+
async metaChanged(groupId, session, by, change, at) {
|
|
16821
17821
|
const name = by === session.myKey ? "You" : session.state.nicks[by] ?? `Member ${by.slice(0, 8)}`;
|
|
16822
|
-
|
|
17822
|
+
const when = at === void 0 ? Date.now() : receivedTimestamp(at);
|
|
17823
|
+
for (const line of metaLines(name, change, session.name)) await this.event(groupId, line.event, line.text, when, session.epoch, by);
|
|
16823
17824
|
this.host.emit();
|
|
16824
17825
|
}
|
|
16825
17826
|
/**
|
|
@@ -16832,7 +17833,7 @@ var Groups = class {
|
|
|
16832
17833
|
const existing = this.host.edges(groupId);
|
|
16833
17834
|
const left = group?.left;
|
|
16834
17835
|
const removed = this.removedAt.get(groupId);
|
|
16835
|
-
const wanted = session?.status === "active" && group ? this.
|
|
17836
|
+
const wanted = session?.status === "active" && group ? this.edgesWanted(groupId, session, group, this.now()) : left ? /* @__PURE__ */ new Set([left.admin, ...left.hubs ?? []]) : removed !== void 0 && this.now() - removed < REMOVED_LINGER_MS ? new Set(existing.keys()) : /* @__PURE__ */ new Set();
|
|
16836
17837
|
for (const [key, linkId] of existing) if (!wanted.has(key)) await this.host.closeEdge(linkId);
|
|
16837
17838
|
const met = this.justMet.get(groupId);
|
|
16838
17839
|
if (session) {
|
|
@@ -16854,11 +17855,9 @@ var Groups = class {
|
|
|
16854
17855
|
this.hubs.met(groupId, key, this.now());
|
|
16855
17856
|
}
|
|
16856
17857
|
/** `member`: whom it is about, so the apps can name them as they are known now, not as they were then. */
|
|
16857
|
-
/** A line in the group's history; lines at the same time (two members gone in one change) get distinct ones, see `
|
|
17858
|
+
/** A line in the group's history; lines at the same time (two members gone in one change) get distinct ones, see `eventTime`. */
|
|
16858
17859
|
async event(groupId, event, text, timestamp, epoch, member) {
|
|
16859
|
-
|
|
16860
|
-
if (timestamp <= last) timestamp = last + 1;
|
|
16861
|
-
this.lastEventAt.set(groupId, Math.max(last, timestamp));
|
|
17860
|
+
timestamp = eventTime(this.eventTimes, groupId, timestamp);
|
|
16862
17861
|
await this.host.storeMessage({
|
|
16863
17862
|
linkId: MESSAGE_LINK(groupId),
|
|
16864
17863
|
id: `event:${epoch}:${event}:${timestamp}`,
|
|
@@ -16870,7 +17869,7 @@ var Groups = class {
|
|
|
16870
17869
|
via: "datalink"
|
|
16871
17870
|
});
|
|
16872
17871
|
}
|
|
16873
|
-
|
|
17872
|
+
eventTimes = /* @__PURE__ */ new Map();
|
|
16874
17873
|
};
|
|
16875
17874
|
//#endregion
|
|
16876
17875
|
//#region ../browser/src/engine/groupEdges.ts
|
|
@@ -16884,7 +17883,7 @@ function edgeView(live, now = Date.now(), noSlot = false) {
|
|
|
16884
17883
|
linkId: live.stored.id,
|
|
16885
17884
|
state: "waiting",
|
|
16886
17885
|
noSlot: true,
|
|
16887
|
-
lastSeenAt: Math.max(live.presence
|
|
17886
|
+
lastSeenAt: Math.max(presenceSeenAt(live.presence), live.lastSyncAt)
|
|
16888
17887
|
};
|
|
16889
17888
|
const error = open ? void 0 : live.pairing?.status === "error" ? live.pairing.error : live.discoveryError;
|
|
16890
17889
|
const state = open ? "open" : error ? "error" : live.dataLink !== "idle" ? "connecting" : "waiting";
|
|
@@ -16892,7 +17891,7 @@ function edgeView(live, now = Date.now(), noSlot = false) {
|
|
|
16892
17891
|
linkId: live.stored.id,
|
|
16893
17892
|
state,
|
|
16894
17893
|
...open && live.pairing?.transport ? { transport: live.pairing.transport } : {},
|
|
16895
|
-
lastSeenAt: open ? now : Math.max(live.presence
|
|
17894
|
+
lastSeenAt: open ? now : Math.max(presenceSeenAt(live.presence), live.lastSyncAt),
|
|
16896
17895
|
...error ? { error } : {}
|
|
16897
17896
|
};
|
|
16898
17897
|
}
|
|
@@ -17379,7 +18378,7 @@ var GroupEdits = class {
|
|
|
17379
18378
|
if (!takes(current) || (current.edit?.seq ?? 0) >= edit.e) return null;
|
|
17380
18379
|
const next = withEdit(current, {
|
|
17381
18380
|
seq: edit.e,
|
|
17382
|
-
at:
|
|
18381
|
+
at: heardTime(edit.ts),
|
|
17383
18382
|
text: edit.m,
|
|
17384
18383
|
card: edit.sc
|
|
17385
18384
|
});
|
|
@@ -17876,6 +18875,14 @@ var POLL_IDLE_MS = 3e5;
|
|
|
17876
18875
|
var RENEW_AFTER_MS = 3456e5;
|
|
17877
18876
|
/** My pointer is published again this often while something is held, in case a relay forgot it. */
|
|
17878
18877
|
var REPUBLISH_MS = 36e5;
|
|
18878
|
+
/**
|
|
18879
|
+
* A held item's address has this long to answer and be read whole, plus the time its declared size takes at the
|
|
18880
|
+
* slowest pace worth waiting for (64 KiB a second): about 21 s for a manifest, two and a half minutes for the largest
|
|
18881
|
+
* item. Past it the read is given up as any failed one is, and the next poll starts there again.
|
|
18882
|
+
*/
|
|
18883
|
+
var HOLD_FETCH_MS = 2e4;
|
|
18884
|
+
var HOLD_FETCH_BYTES_PER_S = 65536;
|
|
18885
|
+
var holdFetchTimeoutMs = (max) => HOLD_FETCH_MS + Math.ceil(max / HOLD_FETCH_BYTES_PER_S) * 1e3;
|
|
17879
18886
|
var hex$2 = (bytes) => Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
|
|
17880
18887
|
/**
|
|
17881
18888
|
* Store-and-forward for away contacts (WISP 4xx, `hold/1`), on both ends of every chat that turned it
|
|
@@ -18054,7 +19061,7 @@ var HoldEngine = class {
|
|
|
18054
19061
|
name: heldName(storage.space, mailbox, seq),
|
|
18055
19062
|
bytes: item.bytes,
|
|
18056
19063
|
ts: item.timestamp,
|
|
18057
|
-
expires: now + this.ttl(),
|
|
19064
|
+
expires: now + Math.min(this.ttl(), HOLD_LIMITS.ttlMs - HOLD_DATED_BACK_MS),
|
|
18058
19065
|
state: "queued"
|
|
18059
19066
|
};
|
|
18060
19067
|
await this.save(linkId, {
|
|
@@ -18252,7 +19259,7 @@ var HoldEngine = class {
|
|
|
18252
19259
|
const now = this.now();
|
|
18253
19260
|
const held = hold.outbox.filter((e) => e.state === "held");
|
|
18254
19261
|
let manifestUrl = null;
|
|
18255
|
-
const
|
|
19262
|
+
const dated = now - HOLD_DATED_BACK_MS, expires = dated + HOLD_LIMITS.ttlMs;
|
|
18256
19263
|
if (held.length && storage && hold.mailbox) {
|
|
18257
19264
|
if (rewriteManifest || !hold.manifestSignedAt) {
|
|
18258
19265
|
const entries = [];
|
|
@@ -18268,7 +19275,7 @@ var HoldEngine = class {
|
|
|
18268
19275
|
mailbox: hold.mailbox,
|
|
18269
19276
|
seq: hold.pointerRev + 1,
|
|
18270
19277
|
id: `manifest-${hold.pointerRev + 1}`,
|
|
18271
|
-
ts:
|
|
19278
|
+
ts: dated,
|
|
18272
19279
|
kind: "manifest",
|
|
18273
19280
|
meta: { entries },
|
|
18274
19281
|
expires
|
|
@@ -18289,7 +19296,7 @@ var HoldEngine = class {
|
|
|
18289
19296
|
}
|
|
18290
19297
|
const pointer = {
|
|
18291
19298
|
rev: hold.pointerRev + 1,
|
|
18292
|
-
issued:
|
|
19299
|
+
issued: dated,
|
|
18293
19300
|
expires,
|
|
18294
19301
|
manifestUrl,
|
|
18295
19302
|
top: hold.outSeq,
|
|
@@ -18366,7 +19373,7 @@ var HoldEngine = class {
|
|
|
18366
19373
|
this.host.changed();
|
|
18367
19374
|
return;
|
|
18368
19375
|
}
|
|
18369
|
-
const pointer = packet ? keys.readPointer(packet
|
|
19376
|
+
const pointer = packet ? keys.readPointer(packet) : null;
|
|
18370
19377
|
if (!pointer) return;
|
|
18371
19378
|
let hold = this.state(linkId);
|
|
18372
19379
|
if (pointer.rev < hold.peerPointerRev) return;
|
|
@@ -18409,7 +19416,7 @@ var HoldEngine = class {
|
|
|
18409
19416
|
});
|
|
18410
19417
|
if (manifest.header.kind !== "manifest") throw new HoldRefusedError("format", "Not a manifest");
|
|
18411
19418
|
mailbox = manifest.header.mailbox;
|
|
18412
|
-
entries = readManifest(manifest.header.meta).filter(([seq]) => seq > this.state(linkId).inSeq);
|
|
19419
|
+
entries = readManifest(manifest.header.meta).filter(([seq]) => seq > this.state(linkId).inSeq).sort((a, b) => a[0] - b[0]);
|
|
18413
19420
|
} catch (error) {
|
|
18414
19421
|
if (error instanceof HoldRefusedError) {
|
|
18415
19422
|
await this.save(linkId, {
|
|
@@ -18527,11 +19534,33 @@ var HoldEngine = class {
|
|
|
18527
19534
|
}
|
|
18528
19535
|
this.host.changed();
|
|
18529
19536
|
}
|
|
18530
|
-
/**
|
|
19537
|
+
/**
|
|
19538
|
+
* A presigned address read whole, but never past `max` bytes: what the manifest declared, not what a server sends.
|
|
19539
|
+
* And never past `holdFetchTimeoutMs(max)`: a request that hangs (no answer, or a body that stops) is aborted and
|
|
19540
|
+
* fails as a network problem does. Before, one hung request held the pickup, and every chat's after it, for good.
|
|
19541
|
+
*/
|
|
18531
19542
|
async fetchBytes(url, max) {
|
|
19543
|
+
const abort = new AbortController();
|
|
19544
|
+
let timer;
|
|
19545
|
+
const late = new Promise((_, reject) => {
|
|
19546
|
+
timer = setTimeout(() => {
|
|
19547
|
+
reject(/* @__PURE__ */ new Error("The storage did not answer in time"));
|
|
19548
|
+
abort.abort();
|
|
19549
|
+
}, holdFetchTimeoutMs(max));
|
|
19550
|
+
});
|
|
19551
|
+
const read = this.readBytes(url, max, abort.signal);
|
|
19552
|
+
read.catch(() => {});
|
|
19553
|
+
try {
|
|
19554
|
+
return await Promise.race([read, late]);
|
|
19555
|
+
} finally {
|
|
19556
|
+
clearTimeout(timer);
|
|
19557
|
+
}
|
|
19558
|
+
}
|
|
19559
|
+
async readBytes(url, max, signal) {
|
|
18532
19560
|
const response = await (this.host.fetch ?? ((...args) => fetch(...args)))(url, {
|
|
18533
19561
|
cache: "no-store",
|
|
18534
|
-
credentials: "omit"
|
|
19562
|
+
credentials: "omit",
|
|
19563
|
+
signal
|
|
18535
19564
|
});
|
|
18536
19565
|
if (!response.ok) throw new Error(`The storage answered ${response.status}`);
|
|
18537
19566
|
if (Number(response.headers.get("content-length")) > max) throw new HoldRefusedError("size", "The held item is larger than the manifest said");
|
|
@@ -18541,6 +19570,9 @@ var HoldEngine = class {
|
|
|
18541
19570
|
return bytes;
|
|
18542
19571
|
}
|
|
18543
19572
|
const reader = response.body.getReader();
|
|
19573
|
+
signal.addEventListener("abort", () => {
|
|
19574
|
+
reader.cancel().catch(() => {});
|
|
19575
|
+
}, { once: true });
|
|
18544
19576
|
const chunks = [];
|
|
18545
19577
|
let size = 0;
|
|
18546
19578
|
for (let next = await reader.read(); !next.done; next = await reader.read()) {
|
|
@@ -19376,7 +20408,7 @@ var ProfilePeek = class {
|
|
|
19376
20408
|
const keys = new HoldKeys(link, link.participationSeed, link.pairedPeerKey);
|
|
19377
20409
|
const packet = await transport.resolve(keys.peerAddress, { background: true });
|
|
19378
20410
|
const now = this.now(), hold = link.hold;
|
|
19379
|
-
const pointer = packet ? keys.readPointer(packet
|
|
20411
|
+
const pointer = packet ? keys.readPointer(packet) : null;
|
|
19380
20412
|
if (pointer && pointer.rev >= hold.peerPointerRev && pointer.expires > now && pointer.top > hold.inSeq) held = Math.min(pointer.count, pointer.top - hold.inSeq);
|
|
19381
20413
|
}
|
|
19382
20414
|
return {
|
|
@@ -19651,6 +20683,10 @@ var S3Store = class {
|
|
|
19651
20683
|
*
|
|
19652
20684
|
* Secrets are random, so there is no seed to restore from yet. `exportTokens`
|
|
19653
20685
|
* is the backup, and balances are meant to stay pocket-money sized.
|
|
20686
|
+
*
|
|
20687
|
+
* Every swap is written down before the mint is asked (`PendingSwap`, or the payment intent of a reviewed payment),
|
|
20688
|
+
* with its blinded outputs and their secrets: the mint signs those outputs and no others, and its signatures can be
|
|
20689
|
+
* asked for again (NUT-09) at any time.
|
|
19654
20690
|
*/
|
|
19655
20691
|
var UNIT$1 = "sat";
|
|
19656
20692
|
var QUOTE_POLL_MS = 4e3;
|
|
@@ -19660,11 +20696,37 @@ var MAX_AMOUNT = 1e6;
|
|
|
19660
20696
|
/** How long "Get test coins" waits for the test mint to mark its invoice paid before saying the coins come later. */
|
|
19661
20697
|
var TEST_COINS_WAIT_MS = 3e4;
|
|
19662
20698
|
var HISTORY_SHOWN = 100;
|
|
20699
|
+
/** How long before a mint that could not say which restored ecash is spent is asked again. */
|
|
20700
|
+
var RESTORED_POLL_MS = 6e4;
|
|
20701
|
+
/** How long before a mint that could not say where a swap stands is asked again. */
|
|
20702
|
+
var SWAP_POLL_MS = 3e4;
|
|
20703
|
+
/** The same, for a swap that holds nothing any more or whose mint cannot give signatures back: asked now and then. */
|
|
20704
|
+
var SWAP_SLOW_POLL_MS = 6e5;
|
|
20705
|
+
/** How long a swap whose inputs were given back is still asked about, in case the mint made it after all. */
|
|
20706
|
+
var SWAP_WATCH_MS = 36e5;
|
|
19663
20707
|
function reviewedCashuFee(preview) {
|
|
19664
20708
|
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();
|
|
19665
20709
|
if (!Number.isSafeInteger(fee) || fee < 0) throw new Error("Invalid Cashu prepared fee");
|
|
19666
20710
|
return fee;
|
|
19667
20711
|
}
|
|
20712
|
+
/**
|
|
20713
|
+
* The mint was asked for a swap and has not answered. The swap is written down: the wallet asks the mint where it
|
|
20714
|
+
* stands until it knows, then stores what it brought or frees what it held. Nothing is known to have failed.
|
|
20715
|
+
*/
|
|
20716
|
+
var SwapUnsettledError = class extends Error {
|
|
20717
|
+
swap;
|
|
20718
|
+
cause;
|
|
20719
|
+
constructor(swap, cause) {
|
|
20720
|
+
const host = new URL(swap.mint).host;
|
|
20721
|
+
super(swap.kind === "receive" ? `${host} has not answered yet. The ecash is added to your wallet as soon as it does.` : `${host} has not answered yet. The payment was not made. The sats set aside for it return to your wallet a few minutes after the mint answers.`);
|
|
20722
|
+
this.swap = swap;
|
|
20723
|
+
this.cause = cause;
|
|
20724
|
+
this.name = "SwapUnsettledError";
|
|
20725
|
+
}
|
|
20726
|
+
};
|
|
20727
|
+
/** When a swap with no answer can no longer reach the mint: a request's longest life, and a margin after its last try. */
|
|
20728
|
+
var swapOverdueAt = (swap) => Math.max(swap.createdAt + CASHU_REQUEST_TIMEOUT_MS + SWAP_SETTLED_MS, (swap.attemptEndedAt ?? Number.POSITIVE_INFINITY) + SWAP_SETTLED_MS);
|
|
20729
|
+
var swapOf = (swap) => swap.swap;
|
|
19668
20730
|
function normalizeMintUrl(input) {
|
|
19669
20731
|
let url;
|
|
19670
20732
|
try {
|
|
@@ -19695,6 +20757,7 @@ var toStored = (mint, p, reserved) => ({
|
|
|
19695
20757
|
});
|
|
19696
20758
|
/** The note of the ecash "Get test coins" brought (the engine plays its own sound for it). */
|
|
19697
20759
|
var TEST_COINS_NOTE = "Test coins from the test mint";
|
|
20760
|
+
var swapId = () => `swap-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
|
19698
20761
|
var walletTx = (mint, kind, amount, fee, note) => ({
|
|
19699
20762
|
id: `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`,
|
|
19700
20763
|
timestamp: Date.now(),
|
|
@@ -19728,8 +20791,13 @@ var CashuWallet = class {
|
|
|
19728
20791
|
locks = /* @__PURE__ */ new Map();
|
|
19729
20792
|
quoteTimer = null;
|
|
19730
20793
|
meltTimer = null;
|
|
20794
|
+
restoredTimer = null;
|
|
20795
|
+
swapTimer = null;
|
|
20796
|
+
swapTimerAt = 0;
|
|
19731
20797
|
/** Per melt quote that ended unpaid: the sats the mint kept all the same (the fee of the split before the melt). */
|
|
19732
20798
|
failedFees = /* @__PURE__ */ new Map();
|
|
20799
|
+
/** Melt quotes that ended unpaid with proofs the mint read spent: ecash spent somewhere else, said as such. */
|
|
20800
|
+
spentAway = /* @__PURE__ */ new Set();
|
|
19733
20801
|
/**
|
|
19734
20802
|
* `getMints`: the mints of one network (the engine's default when none is named), primary first: balances,
|
|
19735
20803
|
* invoices, payments of that network's Cashu wallet. `getKnownMints`: every mint the user added, whatever its
|
|
@@ -19743,6 +20811,8 @@ var CashuWallet = class {
|
|
|
19743
20811
|
start() {
|
|
19744
20812
|
this.pollQuotes();
|
|
19745
20813
|
this.pollMelts();
|
|
20814
|
+
this.checkRestored();
|
|
20815
|
+
this.pollSwaps();
|
|
19746
20816
|
for (const mint of this.getKnownMints()) this.checkMint(mint).then(() => this.events.onChange(), () => {});
|
|
19747
20817
|
}
|
|
19748
20818
|
/** The Cashu wallet of one network: its mints and their balance. History is every network's, newest first. */
|
|
@@ -19756,13 +20826,57 @@ var CashuWallet = class {
|
|
|
19756
20826
|
}));
|
|
19757
20827
|
const all = await wrap((await store(STORES.walletTx, "readonly")).getAll());
|
|
19758
20828
|
all.sort((a, b) => b.timestamp - a.timestamp);
|
|
20829
|
+
const here = new Set(mints.map((m) => m.url));
|
|
20830
|
+
const swaps = (await wrap((await store(STORES.swaps, "readonly")).getAll())).filter((s) => here.has(s.mint) && !s.released && !s.finished);
|
|
20831
|
+
const about = (s) => s.kind === "send" ? swapOf(s).inputs.reduce((sum, p) => sum + Number(p.amount), 0) : s.tx?.amount ?? 0;
|
|
20832
|
+
const spentAway = new Set(swaps.filter((s) => s.stuck && s.kind === "send").flatMap((s) => swapOf(s).inputs.map((p) => p.secret)));
|
|
19759
20833
|
return {
|
|
19760
20834
|
mints,
|
|
19761
20835
|
balance: total(mints.map((m) => ({ amount: m.balance }))),
|
|
20836
|
+
setAside: total(proofs.filter((p) => here.has(p.mint) && p.reserved && !spentAway.has(p.secret))),
|
|
20837
|
+
openSwaps: swaps.length,
|
|
20838
|
+
swapsAmount: total(swaps.map((s) => ({ amount: about(s) }))),
|
|
20839
|
+
unconfirmed: total(swaps.filter((s) => s.stuck).map((s) => ({ amount: about(s) }))),
|
|
19762
20840
|
history: all.slice(0, HISTORY_SHOWN),
|
|
19763
20841
|
feesPaid: all.reduce((sum, tx) => sum + tx.fee, 0)
|
|
19764
20842
|
};
|
|
19765
20843
|
}
|
|
20844
|
+
/**
|
|
20845
|
+
* Ecash a profile restore brought back (`unchecked`) is a copy from when the backup was made. What was spent since
|
|
20846
|
+
* (by the profile it was copied from, or by this one before it was restored) still counted in the balance, for good,
|
|
20847
|
+
* and every payment that picked one of those proofs failed with the mint's "Token already spent". Each mint is asked
|
|
20848
|
+
* once which of them it still reads unspent: a spent proof is dropped, an unspent one is checked for good, and one
|
|
20849
|
+
* the mint holds pending, or a mint that cannot be asked, is asked again later.
|
|
20850
|
+
*/
|
|
20851
|
+
async checkRestored() {
|
|
20852
|
+
if (this.restoredTimer) clearTimeout(this.restoredTimer);
|
|
20853
|
+
this.restoredTimer = null;
|
|
20854
|
+
const waiting = (p) => !!p.unchecked && !p.reserved;
|
|
20855
|
+
let again = false, changed = false;
|
|
20856
|
+
for (const mint of new Set((await this.allProofs()).filter(waiting).map((p) => p.mint))) try {
|
|
20857
|
+
await this.locked(mint, async () => {
|
|
20858
|
+
const proofs = (await this.allProofs()).filter((p) => p.mint === mint && waiting(p));
|
|
20859
|
+
if (!proofs.length) return;
|
|
20860
|
+
const states = await (await this.wallet(mint)).checkProofsStates(proofs);
|
|
20861
|
+
if (states.length !== proofs.length) throw new Error("The mint answered for another number of proofs");
|
|
20862
|
+
await transact([STORES.proofs], (stores) => {
|
|
20863
|
+
proofs.forEach((proof, i) => {
|
|
20864
|
+
const state = states[i]?.state;
|
|
20865
|
+
if (state === "SPENT") stores[STORES.proofs].delete(proof.secret);
|
|
20866
|
+
else if (state === "UNSPENT") {
|
|
20867
|
+
const { unchecked: _checked, ...kept } = proof;
|
|
20868
|
+
stores[STORES.proofs].put(kept);
|
|
20869
|
+
} else again = true;
|
|
20870
|
+
});
|
|
20871
|
+
});
|
|
20872
|
+
changed = true;
|
|
20873
|
+
});
|
|
20874
|
+
} catch {
|
|
20875
|
+
again = true;
|
|
20876
|
+
}
|
|
20877
|
+
if (changed) this.events.onChange();
|
|
20878
|
+
if (again) this.restoredTimer = setTimeout(() => void this.checkRestored(), RESTORED_POLL_MS);
|
|
20879
|
+
}
|
|
19766
20880
|
/** Talks to the mint before it is added: a typo should not become a place to keep money. */
|
|
19767
20881
|
async checkMint(url) {
|
|
19768
20882
|
const normalized = normalizeMintUrl(url);
|
|
@@ -19923,12 +21037,16 @@ var CashuWallet = class {
|
|
|
19923
21037
|
const quote = await wrap((await store(STORES.quotes, "readonly")).get(id));
|
|
19924
21038
|
if (!quote || quote.issuedUnclaimed || quote.held) return null;
|
|
19925
21039
|
const wallet = await this.wallet(quote.mint);
|
|
19926
|
-
const
|
|
21040
|
+
const answer = await wallet.checkMintQuoteBolt11(quote.quote);
|
|
21041
|
+
const { state } = answer;
|
|
19927
21042
|
if (state === "UNPAID") {
|
|
19928
21043
|
if (quote.expiresAt && quote.expiresAt + 6e4 < Date.now()) await wrap((await store(STORES.quotes, "readwrite")).delete(quote.quote));
|
|
19929
21044
|
return null;
|
|
19930
21045
|
}
|
|
21046
|
+
const saved = quote.outputs?.map((o) => OutputData.deserialize(o));
|
|
19931
21047
|
if (state === "ISSUED") {
|
|
21048
|
+
const issued = saved?.length ? await this.signedOutputs(wallet, saved) : void 0;
|
|
21049
|
+
if (Array.isArray(issued)) return this.storeMinted(quote, issued);
|
|
19932
21050
|
console.warn(`[wallet] ${quote.mint} issued quote ${quote.quote} (${quote.amount} sats) but this wallet holds no ecash for it`);
|
|
19933
21051
|
await wrap((await store(STORES.quotes, "readwrite")).put({
|
|
19934
21052
|
...quote,
|
|
@@ -19936,11 +21054,31 @@ var CashuWallet = class {
|
|
|
19936
21054
|
}));
|
|
19937
21055
|
return null;
|
|
19938
21056
|
}
|
|
19939
|
-
|
|
21057
|
+
const preview = await wallet.prepareMint("bolt11", quote.amount, {
|
|
21058
|
+
...answer,
|
|
21059
|
+
quote: quote.quote
|
|
21060
|
+
});
|
|
21061
|
+
if (saved) preview.outputData = saved;
|
|
21062
|
+
const written = {
|
|
19940
21063
|
...quote,
|
|
19941
|
-
paid: true
|
|
19942
|
-
|
|
19943
|
-
|
|
21064
|
+
paid: true,
|
|
21065
|
+
outputs: saved ? quote.outputs : preview.outputData.map((o) => OutputData.serialize(o))
|
|
21066
|
+
};
|
|
21067
|
+
if (!quote.paid || !saved) await wrap((await store(STORES.quotes, "readwrite")).put(written));
|
|
21068
|
+
let proofs;
|
|
21069
|
+
try {
|
|
21070
|
+
proofs = await wallet.completeMint(preview);
|
|
21071
|
+
} catch (error) {
|
|
21072
|
+
if (saved && isMintOperationError(error) && (await wallet.checkMintQuoteBolt11(quote.quote).catch(() => null))?.state === "PAID") {
|
|
21073
|
+
const { outputs: _refused, ...fresh } = written;
|
|
21074
|
+
await wrap((await store(STORES.quotes, "readwrite")).put(fresh));
|
|
21075
|
+
}
|
|
21076
|
+
throw error;
|
|
21077
|
+
}
|
|
21078
|
+
return this.storeMinted(quote, proofs);
|
|
21079
|
+
}
|
|
21080
|
+
/** The ecash of a paid invoice goes in with its history line, in the transaction that drops the quote. */
|
|
21081
|
+
async storeMinted(quote, proofs) {
|
|
19944
21082
|
const minted = sats(proofs);
|
|
19945
21083
|
const tx = walletTx(quote.mint, "lightning-in", minted, quote.amount - minted, quote.paymentId ? "Request paid over Lightning" : quote.testCoins ? TEST_COINS_NOTE : void 0);
|
|
19946
21084
|
await transact([
|
|
@@ -19977,24 +21115,24 @@ var CashuWallet = class {
|
|
|
19977
21115
|
for (const mint of candidates) {
|
|
19978
21116
|
if (await this.balanceAt(mint) < amount) continue;
|
|
19979
21117
|
return this.locked(mint, async () => {
|
|
19980
|
-
const
|
|
19981
|
-
const proofs = await this.proofsAt(mint);
|
|
19982
|
-
const { keep, send } = await wallet.send(amount, asProofLike(proofs), { includeFees: true });
|
|
21118
|
+
const { inputs, keep, send, swap } = await this.split(mint, amount, await this.proofsAt(mint));
|
|
19983
21119
|
const token = getEncodedToken({
|
|
19984
21120
|
mint,
|
|
19985
21121
|
proofs: send,
|
|
19986
21122
|
unit: UNIT$1
|
|
19987
21123
|
});
|
|
19988
|
-
const spent = total(
|
|
21124
|
+
const spent = total(inputs) - sats(keep);
|
|
19989
21125
|
const tx = walletTx(mint, "ecash-out", amount, spent - amount, note);
|
|
19990
21126
|
const kept = outbox?.(token, mint);
|
|
19991
21127
|
await transact([
|
|
19992
21128
|
STORES.proofs,
|
|
19993
21129
|
STORES.walletTx,
|
|
21130
|
+
STORES.swaps,
|
|
19994
21131
|
...kept ? [STORES.payments] : []
|
|
19995
21132
|
], (stores) => {
|
|
19996
|
-
this.queueReplace(stores[STORES.proofs], mint,
|
|
21133
|
+
this.queueReplace(stores[STORES.proofs], mint, inputs, keep);
|
|
19997
21134
|
stores[STORES.walletTx].put(tx);
|
|
21135
|
+
if (swap) stores[STORES.swaps].delete(swap.id);
|
|
19998
21136
|
if (kept) stores[STORES.payments].put(kept);
|
|
19999
21137
|
});
|
|
20000
21138
|
this.events.onChange();
|
|
@@ -20057,8 +21195,31 @@ var CashuWallet = class {
|
|
|
20057
21195
|
const keep = (prepared.swap.keepOutputs ?? []).map(OutputData.deserialize);
|
|
20058
21196
|
const send = (prepared.swap.sendOutputs ?? []).map(OutputData.deserialize);
|
|
20059
21197
|
const outputs = [...keep, ...send];
|
|
21198
|
+
const restored = await this.restoreOutputs(wallet, outputs);
|
|
21199
|
+
if (!Array.isArray(restored)) return void 0;
|
|
21200
|
+
return this.finishReviewedCashu(review, prepared, restored.slice(0, keep.length), restored.slice(keep.length));
|
|
21201
|
+
}
|
|
21202
|
+
/**
|
|
21203
|
+
* What the mint says of outputs that were written down before it was asked to sign them: the proofs they stand for
|
|
21204
|
+
* when it signed them all, `none` when it answered that it signed none, and undefined when it has no way of saying
|
|
21205
|
+
* (it does not restore, NUT-09, by its own info). Only an answer counts: a mint that cannot be reached, answers with
|
|
21206
|
+
* an error or answers for some of the outputs throws, and is asked again later.
|
|
21207
|
+
*/
|
|
21208
|
+
async signedOutputs(wallet, outputs) {
|
|
21209
|
+
if (!wallet.getMintInfo().isSupported(9).supported) return void 0;
|
|
21210
|
+
const restored = await this.restoreOutputs(wallet, outputs);
|
|
21211
|
+
if (restored === "some") throw new Error("The mint answered for some of the outputs");
|
|
21212
|
+
return restored;
|
|
21213
|
+
}
|
|
21214
|
+
/**
|
|
21215
|
+
* The mint's signatures for these exact outputs (NUT-09), as the proofs they stand for: all of them, `none`, or
|
|
21216
|
+
* `some` (an answer that does not cover every output). It only reads what the mint already signed: nothing is
|
|
21217
|
+
* swapped again.
|
|
21218
|
+
*/
|
|
21219
|
+
async restoreOutputs(wallet, outputs) {
|
|
20060
21220
|
const recovered = await wallet.mint.restore({ outputs: outputs.map((o) => o.blindedMessage) });
|
|
20061
|
-
if (recovered.outputs.length
|
|
21221
|
+
if (recovered.outputs.length === 0 && recovered.signatures.length === 0) return "none";
|
|
21222
|
+
if (recovered.outputs.length !== outputs.length || recovered.signatures.length !== outputs.length) return "some";
|
|
20062
21223
|
const keys = (await wallet.mint.getKeys()).keysets;
|
|
20063
21224
|
const restored = [];
|
|
20064
21225
|
for (const output of outputs) {
|
|
@@ -20068,7 +21229,7 @@ var CashuWallet = class {
|
|
|
20068
21229
|
if (!signature || !key || signature.id !== output.blindedMessage.id || Amount.from(signature.amount).toString() !== Amount.from(output.blindedMessage.amount).toString()) throw new Error("Mint restore does not match reviewed outputs");
|
|
20069
21230
|
restored.push(output.toProof(signature, key));
|
|
20070
21231
|
}
|
|
20071
|
-
return
|
|
21232
|
+
return restored;
|
|
20072
21233
|
}
|
|
20073
21234
|
async finishReviewedCashu(review, prepared, keep, send) {
|
|
20074
21235
|
const token = getEncodedToken({
|
|
@@ -20125,6 +21286,72 @@ var CashuWallet = class {
|
|
|
20125
21286
|
const restored = await wallet.mint.restore({ outputs: outputs.map((o) => o.blindedMessage) });
|
|
20126
21287
|
return restored.signatures.length === 0 && restored.outputs.length === 0;
|
|
20127
21288
|
}
|
|
21289
|
+
/**
|
|
21290
|
+
* Whether a reviewed swap can never happen because its ecash was spent by something else: the mint answers that it
|
|
21291
|
+
* signed none of its outputs (a successful NUT-09 answer, asked before and after), and reads an input spent, none
|
|
21292
|
+
* pending. Read-only. A mint that does not restore cannot say, and the answer is no.
|
|
21293
|
+
*/
|
|
21294
|
+
async reviewedCashuSpentElsewhere(prepared) {
|
|
21295
|
+
const preview = deserializeSwapPreview(prepared.swap);
|
|
21296
|
+
if (!preview.inputs.length) return false;
|
|
21297
|
+
const wallet = await this.wallet(prepared.mint);
|
|
21298
|
+
const outputs = [...prepared.swap.keepOutputs ?? [], ...prepared.swap.sendOutputs ?? []].map(OutputData.deserialize);
|
|
21299
|
+
if (await this.signedOutputs(wallet, outputs) !== "none") return false;
|
|
21300
|
+
const states = await wallet.checkProofsStates(preview.inputs.map((p) => ({
|
|
21301
|
+
secret: p.secret,
|
|
21302
|
+
id: p.id
|
|
21303
|
+
})));
|
|
21304
|
+
if (states.length !== preview.inputs.length || states.some((s) => s.state === "PENDING") || !states.some((s) => s.state === "SPENT")) return false;
|
|
21305
|
+
return await this.signedOutputs(wallet, outputs) === "none";
|
|
21306
|
+
}
|
|
21307
|
+
/**
|
|
21308
|
+
* Ends a reviewed swap whose ecash was spent elsewhere: the inputs the mint reads spent leave the wallet, the others
|
|
21309
|
+
* are free again, and the rest of the wallet's ecash at that mint is checked the same way. Idempotent.
|
|
21310
|
+
*/
|
|
21311
|
+
async dropReviewedCashu(prepared) {
|
|
21312
|
+
const preview = deserializeSwapPreview(prepared.swap);
|
|
21313
|
+
await this.locked(prepared.mint, async () => {
|
|
21314
|
+
const states = await (await this.wallet(prepared.mint)).checkProofsStates(preview.inputs.map((p) => ({
|
|
21315
|
+
secret: p.secret,
|
|
21316
|
+
id: p.id
|
|
21317
|
+
})));
|
|
21318
|
+
if (states.length !== preview.inputs.length) throw new Error("The mint answered for another number of proofs");
|
|
21319
|
+
const spent = new Set(preview.inputs.filter((_, i) => states[i]?.state === "SPENT").map((p) => p.secret));
|
|
21320
|
+
await transact([STORES.proofs], (stores) => {
|
|
21321
|
+
const proofs = stores[STORES.proofs];
|
|
21322
|
+
for (const input of preview.inputs) {
|
|
21323
|
+
const request = proofs.get(input.secret);
|
|
21324
|
+
request.onsuccess = () => {
|
|
21325
|
+
const proof = request.result;
|
|
21326
|
+
if (!proof || !proof.reserved || proof.mint !== prepared.mint || proof.C !== input.C) return;
|
|
21327
|
+
if (spent.has(input.secret)) {
|
|
21328
|
+
proofs.delete(input.secret);
|
|
21329
|
+
return;
|
|
21330
|
+
}
|
|
21331
|
+
const { reserved: _released, ...free } = proof;
|
|
21332
|
+
proofs.put(free);
|
|
21333
|
+
};
|
|
21334
|
+
}
|
|
21335
|
+
});
|
|
21336
|
+
});
|
|
21337
|
+
await this.recheck(prepared.mint);
|
|
21338
|
+
this.events.onChange();
|
|
21339
|
+
}
|
|
21340
|
+
/**
|
|
21341
|
+
* A proof of this wallet read spent when nothing here spent it: its ecash is in use somewhere else too. Every other
|
|
21342
|
+
* free proof at that mint is asked about once (`checkRestored`), so the balance becomes what the mint still holds
|
|
21343
|
+
* instead of one payment after another finding out.
|
|
21344
|
+
*/
|
|
21345
|
+
async recheck(mint) {
|
|
21346
|
+
const free = (await this.allProofs()).filter((p) => p.mint === mint && !p.reserved && !p.unchecked);
|
|
21347
|
+
if (free.length > 0) await transact([STORES.proofs], (stores) => {
|
|
21348
|
+
for (const p of free) stores[STORES.proofs].put({
|
|
21349
|
+
...p,
|
|
21350
|
+
unchecked: true
|
|
21351
|
+
});
|
|
21352
|
+
});
|
|
21353
|
+
await this.checkRestored();
|
|
21354
|
+
}
|
|
20128
21355
|
/** Gives back the inputs a reviewed swap reserved, once the mint proved it never happened. Idempotent. */
|
|
20129
21356
|
async releaseReviewedCashu(prepared) {
|
|
20130
21357
|
const preview = deserializeSwapPreview(prepared.swap);
|
|
@@ -20144,6 +21371,13 @@ var CashuWallet = class {
|
|
|
20144
21371
|
});
|
|
20145
21372
|
this.events.onChange();
|
|
20146
21373
|
}
|
|
21374
|
+
/**
|
|
21375
|
+
* Whether the ecash of a reviewed payment came back to this wallet (Take it back, or a refusal). Its proofs then read
|
|
21376
|
+
* SPENT at the mint because this wallet redeemed them, not the contact.
|
|
21377
|
+
*/
|
|
21378
|
+
async reviewedCashuTakenBack(id) {
|
|
21379
|
+
return (await wrap((await store(STORES.payments, "readonly")).get(id)))?.state === "reclaimed";
|
|
21380
|
+
}
|
|
20147
21381
|
async reviewedCashuSpent(prepared) {
|
|
20148
21382
|
const outputs = (prepared.swap.sendOutputs ?? []).map(OutputData.deserialize);
|
|
20149
21383
|
const states = await (await this.wallet(prepared.mint)).checkProofsStates(outputs.map((o) => ({
|
|
@@ -20199,18 +21433,40 @@ var CashuWallet = class {
|
|
|
20199
21433
|
if (addTestMint && !this.getKnownMints().includes(mint) && isTestMint(mint)) await this.events.onTestMintNeeded(mint);
|
|
20200
21434
|
if (!this.getKnownMints().includes(mint)) throw new Error(`Ecash from ${new URL(mint).hostname} is not accepted`);
|
|
20201
21435
|
return this.locked(mint, async () => {
|
|
20202
|
-
const
|
|
20203
|
-
|
|
20204
|
-
|
|
20205
|
-
await transact([
|
|
20206
|
-
STORES.proofs,
|
|
20207
|
-
STORES.walletTx,
|
|
20208
|
-
...payment ? [STORES.payments] : []
|
|
20209
|
-
], (stores) => {
|
|
20210
|
-
for (const p of proofs) stores[STORES.proofs].put(toStored(mint, p));
|
|
20211
|
-
stores[STORES.walletTx].put(tx);
|
|
20212
|
-
if (payment) stores[STORES.payments].put(payment(credited, mint));
|
|
21436
|
+
const records = (credited) => ({
|
|
21437
|
+
tx: walletTx(mint, kind, credited, Math.max(0, faceValue - credited), note),
|
|
21438
|
+
...payment ? { payment: payment(credited, mint) } : {}
|
|
20213
21439
|
});
|
|
21440
|
+
let swap = (await this.swapsAt(mint)).find((s) => s.kind === "receive" && s.token === token);
|
|
21441
|
+
if (swap?.finished) return {
|
|
21442
|
+
amount: swap.tx?.amount ?? 0,
|
|
21443
|
+
mint
|
|
21444
|
+
};
|
|
21445
|
+
if (!swap) {
|
|
21446
|
+
const preview = await (await this.wallet(mint)).prepareSwapToReceive(token);
|
|
21447
|
+
swap = {
|
|
21448
|
+
id: swapId(),
|
|
21449
|
+
mint,
|
|
21450
|
+
kind: "receive",
|
|
21451
|
+
swap: serializeSwapPreview(preview),
|
|
21452
|
+
createdAt: Date.now(),
|
|
21453
|
+
token,
|
|
21454
|
+
...records(preview.amount.toNumber())
|
|
21455
|
+
};
|
|
21456
|
+
await wrap((await store(STORES.swaps, "readwrite")).put(swap));
|
|
21457
|
+
}
|
|
21458
|
+
const { keep } = await this.completeSwap(swap);
|
|
21459
|
+
const credited = sats(keep);
|
|
21460
|
+
if (swap.tx && credited !== swap.tx.amount) swap = {
|
|
21461
|
+
...swap,
|
|
21462
|
+
...records(credited),
|
|
21463
|
+
tx: {
|
|
21464
|
+
...records(credited).tx,
|
|
21465
|
+
id: swap.tx.id,
|
|
21466
|
+
timestamp: swap.tx.timestamp
|
|
21467
|
+
}
|
|
21468
|
+
};
|
|
21469
|
+
await this.finishSwap(swap, keep, [], false);
|
|
20214
21470
|
this.events.onChange();
|
|
20215
21471
|
return {
|
|
20216
21472
|
amount: credited,
|
|
@@ -20218,6 +21474,334 @@ var CashuWallet = class {
|
|
|
20218
21474
|
};
|
|
20219
21475
|
});
|
|
20220
21476
|
}
|
|
21477
|
+
/**
|
|
21478
|
+
* Splits `amount`, with the fee its receiver will pay to redeem it, out of `proofs`. Coins that add up exactly need
|
|
21479
|
+
* no mint. Otherwise it is a swap: written down, with its inputs reserved, before the mint is asked. The caller
|
|
21480
|
+
* stores the result in one transaction: `inputs` out, `keep` in, `send` where it goes, and `swap` deleted.
|
|
21481
|
+
* Runs under the mint's lock.
|
|
21482
|
+
*/
|
|
21483
|
+
async split(mint, amount, proofs) {
|
|
21484
|
+
const wallet = await this.wallet(mint);
|
|
21485
|
+
const among = (chosen) => {
|
|
21486
|
+
const secrets = new Set(chosen.map((p) => p.secret));
|
|
21487
|
+
return proofs.filter((p) => secrets.has(p.secret));
|
|
21488
|
+
};
|
|
21489
|
+
try {
|
|
21490
|
+
const { send } = wallet.sendOffline(amount, asProofLike(proofs), {
|
|
21491
|
+
includeFees: true,
|
|
21492
|
+
exactMatch: true
|
|
21493
|
+
});
|
|
21494
|
+
if (send.length > 0 && sats(send) === amount + wallet.getFeesForProofs(send).toNumber()) return {
|
|
21495
|
+
inputs: among(send),
|
|
21496
|
+
keep: [],
|
|
21497
|
+
send
|
|
21498
|
+
};
|
|
21499
|
+
} catch {}
|
|
21500
|
+
const preview = await wallet.prepareSwapToSend(amount, asProofLike(proofs), { includeFees: true });
|
|
21501
|
+
const swap = {
|
|
21502
|
+
id: swapId(),
|
|
21503
|
+
mint,
|
|
21504
|
+
kind: "send",
|
|
21505
|
+
swap: serializeSwapPreview(preview),
|
|
21506
|
+
createdAt: Date.now()
|
|
21507
|
+
};
|
|
21508
|
+
const inputs = among(preview.inputs);
|
|
21509
|
+
await transact([STORES.proofs, STORES.swaps], (stores) => {
|
|
21510
|
+
for (const p of inputs) stores[STORES.proofs].put({
|
|
21511
|
+
...p,
|
|
21512
|
+
reserved: true
|
|
21513
|
+
});
|
|
21514
|
+
stores[STORES.swaps].put(swap);
|
|
21515
|
+
});
|
|
21516
|
+
const { keep, send } = await this.completeSwap(swap);
|
|
21517
|
+
return {
|
|
21518
|
+
inputs,
|
|
21519
|
+
keep,
|
|
21520
|
+
send,
|
|
21521
|
+
swap
|
|
21522
|
+
};
|
|
21523
|
+
}
|
|
21524
|
+
/**
|
|
21525
|
+
* Sends a written-down swap to the mint: always the saved one, so a second try asks for the same outputs. When the
|
|
21526
|
+
* answer is missed the mint is asked what it did, and its word decides: the outputs it signed are the result, as the
|
|
21527
|
+
* answer would have been; a swap it says it never made is undone and the failure is thrown; and for as long as the
|
|
21528
|
+
* swap stays written down nothing is known to have failed, whatever the mint's words were (`SwapUnsettledError`):
|
|
21529
|
+
* `pollSwaps` settles it. Runs under the mint's lock.
|
|
21530
|
+
*/
|
|
21531
|
+
async completeSwap(swap) {
|
|
21532
|
+
try {
|
|
21533
|
+
return await (await this.wallet(swap.mint)).completeSwap(deserializeSwapPreview(swapOf(swap)));
|
|
21534
|
+
} catch (error) {
|
|
21535
|
+
const refused = isMintOperationError(error) && swap.attemptEndedAt === void 0;
|
|
21536
|
+
const ended = {
|
|
21537
|
+
...swap,
|
|
21538
|
+
attemptEndedAt: Date.now()
|
|
21539
|
+
};
|
|
21540
|
+
await (async () => wrap((await store(STORES.swaps, "readwrite")).put(ended)))().catch(() => {});
|
|
21541
|
+
const outcome = await this.swapOutcome(ended, refused).catch(() => ({ state: "open" }));
|
|
21542
|
+
if (outcome.state === "done") return outcome;
|
|
21543
|
+
if (outcome.state === "never" && outcome.final) {
|
|
21544
|
+
await this.dropSwap(ended, outcome.gone);
|
|
21545
|
+
throw outcome.gone.size > 0 ? Object.assign(engineError("ecashAlreadySpent"), { cause: error }) : error;
|
|
21546
|
+
}
|
|
21547
|
+
this.swapsAgainIn(SWAP_POLL_MS);
|
|
21548
|
+
this.events.onChange();
|
|
21549
|
+
throw new SwapUnsettledError(ended, error);
|
|
21550
|
+
}
|
|
21551
|
+
}
|
|
21552
|
+
/**
|
|
21553
|
+
* Asks the mint where a written-down swap stands. It only asks: nothing is sent again, nothing is stored.
|
|
21554
|
+
* `refused`: the mint refused the only request ever made for this swap, so no request of it is still on its way.
|
|
21555
|
+
* Throws when the mint cannot be asked, or answers with an error: only its answers count.
|
|
21556
|
+
*/
|
|
21557
|
+
async swapOutcome(swap, refused = false) {
|
|
21558
|
+
const wallet = await this.wallet(swap.mint);
|
|
21559
|
+
const saved = swapOf(swap);
|
|
21560
|
+
const keep = (saved.keepOutputs ?? []).map(OutputData.deserialize);
|
|
21561
|
+
const send = (saved.sendOutputs ?? []).map(OutputData.deserialize);
|
|
21562
|
+
const signed = async () => {
|
|
21563
|
+
const proofs = await this.signedOutputs(wallet, [...keep, ...send]);
|
|
21564
|
+
return Array.isArray(proofs) ? {
|
|
21565
|
+
state: "done",
|
|
21566
|
+
keep: proofs.slice(0, keep.length),
|
|
21567
|
+
send: proofs.slice(keep.length)
|
|
21568
|
+
} : proofs;
|
|
21569
|
+
};
|
|
21570
|
+
const first = await signed();
|
|
21571
|
+
if (first && first !== "none") return first;
|
|
21572
|
+
const states = await wallet.checkProofsStates(saved.inputs.map((p) => ({
|
|
21573
|
+
secret: p.secret,
|
|
21574
|
+
id: p.id
|
|
21575
|
+
})));
|
|
21576
|
+
if (states.length !== saved.inputs.length) throw new Error("The mint answered for another number of proofs");
|
|
21577
|
+
if (states.some((s) => s.state === "PENDING")) return { state: "open" };
|
|
21578
|
+
const gone = new Set(saved.inputs.filter((_, i) => states[i]?.state === "SPENT").map((p) => p.secret));
|
|
21579
|
+
if (gone.size > 0) {
|
|
21580
|
+
const second = await signed();
|
|
21581
|
+
if (second && second !== "none") return second;
|
|
21582
|
+
if (second === "none" || refused) return {
|
|
21583
|
+
state: "never",
|
|
21584
|
+
gone,
|
|
21585
|
+
final: true
|
|
21586
|
+
};
|
|
21587
|
+
return {
|
|
21588
|
+
state: "open",
|
|
21589
|
+
slow: true
|
|
21590
|
+
};
|
|
21591
|
+
}
|
|
21592
|
+
if (refused) return {
|
|
21593
|
+
state: "never",
|
|
21594
|
+
gone,
|
|
21595
|
+
final: true
|
|
21596
|
+
};
|
|
21597
|
+
if (Date.now() >= swapOverdueAt(swap)) return {
|
|
21598
|
+
state: "never",
|
|
21599
|
+
gone,
|
|
21600
|
+
final: false
|
|
21601
|
+
};
|
|
21602
|
+
return {
|
|
21603
|
+
state: "open",
|
|
21604
|
+
idle: !!first
|
|
21605
|
+
};
|
|
21606
|
+
}
|
|
21607
|
+
/**
|
|
21608
|
+
* Stores what a swap brought, in one transaction that reads the swap first: a swap still written down was never
|
|
21609
|
+
* stored, and one stored is never stored again, whoever else works on this database. A redeem brings its ecash with
|
|
21610
|
+
* its history line and its payment record. A split finished here was not finished by its caller (the payment it was
|
|
21611
|
+
* for is over): everything it made is the wallet's. `late`: after the call that made it was over; a swap with a
|
|
21612
|
+
* payment then stays, marked `finished`, until the chat has been told (`tellSwap`). Returns whether it stored.
|
|
21613
|
+
*/
|
|
21614
|
+
async finishSwap(swap, keep, send, late) {
|
|
21615
|
+
const secrets = new Set(swap.kind === "send" ? swapOf(swap).inputs.map((p) => p.secret) : []);
|
|
21616
|
+
const inputs = (await this.allProofs()).filter((p) => p.mint === swap.mint && secrets.has(p.secret));
|
|
21617
|
+
const lost = swap.kind === "send" ? swapOf(swap).inputs.reduce((sum, p) => sum + Number(p.amount), 0) - sats(keep) - sats(send) : 0;
|
|
21618
|
+
let stored = false;
|
|
21619
|
+
await transact([
|
|
21620
|
+
STORES.proofs,
|
|
21621
|
+
STORES.swaps,
|
|
21622
|
+
STORES.walletTx,
|
|
21623
|
+
...swap.payment ? [STORES.payments] : []
|
|
21624
|
+
], (stores) => {
|
|
21625
|
+
const current = stores[STORES.swaps].get(swap.id);
|
|
21626
|
+
current.onsuccess = () => {
|
|
21627
|
+
const kept = current.result;
|
|
21628
|
+
if (!kept || kept.finished) return;
|
|
21629
|
+
stored = true;
|
|
21630
|
+
for (const p of inputs) stores[STORES.proofs].delete(p.secret);
|
|
21631
|
+
for (const p of [...keep, ...send]) stores[STORES.proofs].put({
|
|
21632
|
+
...toStored(swap.mint, p),
|
|
21633
|
+
...swap.restored ? { unchecked: true } : {}
|
|
21634
|
+
});
|
|
21635
|
+
if (swap.tx) stores[STORES.walletTx].put(swap.tx);
|
|
21636
|
+
if (lost > 0) stores[STORES.walletTx].put({
|
|
21637
|
+
...walletTx(swap.mint, "fee", 0, lost, "A payment was not sent"),
|
|
21638
|
+
id: swap.id
|
|
21639
|
+
});
|
|
21640
|
+
if (swap.payment) stores[STORES.payments].put(swap.payment);
|
|
21641
|
+
if (late && swap.payment) stores[STORES.swaps].put({
|
|
21642
|
+
...kept,
|
|
21643
|
+
finished: true
|
|
21644
|
+
});
|
|
21645
|
+
else stores[STORES.swaps].delete(swap.id);
|
|
21646
|
+
};
|
|
21647
|
+
});
|
|
21648
|
+
if (stored && swap.restored) this.checkRestored();
|
|
21649
|
+
return stored;
|
|
21650
|
+
}
|
|
21651
|
+
/**
|
|
21652
|
+
* The mint never made this swap. The inputs it held are free again, less any the mint reads spent. `watch`: it is
|
|
21653
|
+
* only overdue, so it stays written down, marked `released`, and is asked about for a while longer: should the mint
|
|
21654
|
+
* have made it after all, what it made is still recovered.
|
|
21655
|
+
*/
|
|
21656
|
+
async dropSwap(swap, gone, watch = false) {
|
|
21657
|
+
const inputs = swap.kind === "send" && !swap.released ? await this.storedInputs(swap) : [];
|
|
21658
|
+
if (gone.size > 0 && inputs.length > 0) console.warn(`[wallet] ${gone.size} proofs of a swap at ${swap.mint} that never happened are spent`);
|
|
21659
|
+
await transact([STORES.proofs, STORES.swaps], (stores) => {
|
|
21660
|
+
for (const p of inputs) if (gone.has(p.secret)) stores[STORES.proofs].delete(p.secret);
|
|
21661
|
+
else {
|
|
21662
|
+
const { reserved: _released, ...free } = p;
|
|
21663
|
+
stores[STORES.proofs].put(free);
|
|
21664
|
+
}
|
|
21665
|
+
if (watch) stores[STORES.swaps].put({
|
|
21666
|
+
...swap,
|
|
21667
|
+
released: true
|
|
21668
|
+
});
|
|
21669
|
+
else stores[STORES.swaps].delete(swap.id);
|
|
21670
|
+
});
|
|
21671
|
+
if (gone.size > 0 && inputs.length > 0) this.recheck(swap.mint).catch(() => {});
|
|
21672
|
+
}
|
|
21673
|
+
/** The proofs a split reserved, as they are stored now. */
|
|
21674
|
+
async storedInputs(swap) {
|
|
21675
|
+
const secrets = new Set(swapOf(swap).inputs.map((p) => p.secret));
|
|
21676
|
+
return (await this.allProofs()).filter((p) => p.mint === swap.mint && p.reserved && secrets.has(p.secret));
|
|
21677
|
+
}
|
|
21678
|
+
async swapsAt(mint) {
|
|
21679
|
+
return (await wrap((await store(STORES.swaps, "readonly")).getAll())).filter((s) => s.mint === mint);
|
|
21680
|
+
}
|
|
21681
|
+
/** Says what became of a swap to whoever follows it (the chat its payment is in), and waits for it to be taken in. */
|
|
21682
|
+
async tellSwap(swap, done) {
|
|
21683
|
+
try {
|
|
21684
|
+
await this.events.onSwapSettled?.(swap, done);
|
|
21685
|
+
return true;
|
|
21686
|
+
} catch {
|
|
21687
|
+
return false;
|
|
21688
|
+
}
|
|
21689
|
+
}
|
|
21690
|
+
swapsAgainIn(ms) {
|
|
21691
|
+
if (this.swapTimer && this.swapTimerAt <= Date.now() + ms) return;
|
|
21692
|
+
if (this.swapTimer) clearTimeout(this.swapTimer);
|
|
21693
|
+
this.swapTimerAt = Date.now() + ms;
|
|
21694
|
+
this.swapTimer = setTimeout(() => void this.pollSwaps(), ms);
|
|
21695
|
+
}
|
|
21696
|
+
/**
|
|
21697
|
+
* Swaps still written down: the app closed while the mint was being asked, or the mint did not answer. Each mint is
|
|
21698
|
+
* asked where its own stand, under its lock. A swap it made is stored as its answer would have been; one it says it
|
|
21699
|
+
* never made is undone; one that is only overdue gives its inputs back and stays watched; one it cannot speak for
|
|
21700
|
+
* is asked about again. `mints`: only these mints', once, now.
|
|
21701
|
+
*/
|
|
21702
|
+
/** Asks these mints about their open swaps now (before a wallet's removal is shown or refused). */
|
|
21703
|
+
settleSwaps(mints) {
|
|
21704
|
+
return this.pollSwaps(mints);
|
|
21705
|
+
}
|
|
21706
|
+
async pollSwaps(mints) {
|
|
21707
|
+
if (!mints) {
|
|
21708
|
+
if (this.swapTimer) clearTimeout(this.swapTimer);
|
|
21709
|
+
this.swapTimer = null;
|
|
21710
|
+
}
|
|
21711
|
+
const open = (await wrap((await store(STORES.swaps, "readonly")).getAll())).filter((s) => !mints || mints.includes(s.mint));
|
|
21712
|
+
let again = 0;
|
|
21713
|
+
const later = (ms) => {
|
|
21714
|
+
again = again ? Math.min(again, ms) : ms;
|
|
21715
|
+
};
|
|
21716
|
+
for (const mint of new Set(open.map((s) => s.mint))) await this.locked(mint, async () => {
|
|
21717
|
+
for (let swap of await this.swapsAt(mint)) try {
|
|
21718
|
+
if (swap.finished) {
|
|
21719
|
+
if (await this.tellSwap(swap, true)) await wrap((await store(STORES.swaps, "readwrite")).delete(swap.id));
|
|
21720
|
+
else later(SWAP_POLL_MS);
|
|
21721
|
+
continue;
|
|
21722
|
+
}
|
|
21723
|
+
if (swap.askAt && Date.now() < swap.askAt && !mints) {
|
|
21724
|
+
later(swap.askAt - Date.now());
|
|
21725
|
+
continue;
|
|
21726
|
+
}
|
|
21727
|
+
if (swap.attemptEndedAt === void 0) {
|
|
21728
|
+
swap = {
|
|
21729
|
+
...swap,
|
|
21730
|
+
attemptEndedAt: Date.now()
|
|
21731
|
+
};
|
|
21732
|
+
await wrap((await store(STORES.swaps, "readwrite")).put(swap));
|
|
21733
|
+
}
|
|
21734
|
+
const outcome = await this.swapOutcome(swap);
|
|
21735
|
+
if (outcome.state === "open") {
|
|
21736
|
+
if (swap.released && Date.now() >= swap.createdAt + SWAP_WATCH_MS) {
|
|
21737
|
+
await this.dropSwap(swap, /* @__PURE__ */ new Set());
|
|
21738
|
+
continue;
|
|
21739
|
+
}
|
|
21740
|
+
if (outcome.slow) {
|
|
21741
|
+
const stuck = (swap.stuck ?? 0) + 1;
|
|
21742
|
+
const wait = stuck <= 6 ? SWAP_SLOW_POLL_MS : stuck <= 30 ? 36e5 : 864e5;
|
|
21743
|
+
await wrap((await store(STORES.swaps, "readwrite")).put({
|
|
21744
|
+
...swap,
|
|
21745
|
+
stuck,
|
|
21746
|
+
askAt: Date.now() + wait
|
|
21747
|
+
}));
|
|
21748
|
+
if (stuck === 1) this.events.onChange();
|
|
21749
|
+
later(wait);
|
|
21750
|
+
} else if (outcome.idle && swap.kind === "receive" && !swap.released) try {
|
|
21751
|
+
const { keep } = await (await this.wallet(mint)).completeSwap(deserializeSwapPreview(swapOf(swap)));
|
|
21752
|
+
if (await this.finishSwap(swap, keep, [], true)) {
|
|
21753
|
+
const told = await this.tellSwap(swap, true);
|
|
21754
|
+
if (swap.payment && told) await wrap((await store(STORES.swaps, "readwrite")).delete(swap.id));
|
|
21755
|
+
else if (swap.payment) later(SWAP_POLL_MS);
|
|
21756
|
+
this.events.onChange();
|
|
21757
|
+
}
|
|
21758
|
+
} catch (error) {
|
|
21759
|
+
swap = {
|
|
21760
|
+
...swap,
|
|
21761
|
+
attemptEndedAt: Date.now()
|
|
21762
|
+
};
|
|
21763
|
+
await wrap((await store(STORES.swaps, "readwrite")).put(swap));
|
|
21764
|
+
const refusal = isMintOperationError(error) ? await this.swapOutcome(swap, true).catch(() => void 0) : void 0;
|
|
21765
|
+
if (refusal?.state === "done") {
|
|
21766
|
+
if (await this.finishSwap(swap, refusal.keep, refusal.send, true)) {
|
|
21767
|
+
const told = await this.tellSwap(swap, true);
|
|
21768
|
+
if (swap.payment && told) await wrap((await store(STORES.swaps, "readwrite")).delete(swap.id));
|
|
21769
|
+
}
|
|
21770
|
+
this.events.onChange();
|
|
21771
|
+
} else if (refusal?.state === "never") {
|
|
21772
|
+
await this.dropSwap(swap, refusal.gone);
|
|
21773
|
+
await this.tellSwap(swap, false);
|
|
21774
|
+
this.events.onChange();
|
|
21775
|
+
} else later(SWAP_POLL_MS);
|
|
21776
|
+
}
|
|
21777
|
+
else later(swap.released ? SWAP_SLOW_POLL_MS : SWAP_POLL_MS);
|
|
21778
|
+
continue;
|
|
21779
|
+
}
|
|
21780
|
+
if (outcome.state === "done") {
|
|
21781
|
+
if (await this.finishSwap(swap, outcome.keep, outcome.send, true)) {
|
|
21782
|
+
const told = await this.tellSwap(swap, true);
|
|
21783
|
+
if (swap.payment && told) await wrap((await store(STORES.swaps, "readwrite")).delete(swap.id));
|
|
21784
|
+
else if (swap.payment) later(SWAP_POLL_MS);
|
|
21785
|
+
}
|
|
21786
|
+
} else if (swap.released) {
|
|
21787
|
+
if (outcome.final || Date.now() >= swap.createdAt + SWAP_WATCH_MS) await this.dropSwap(swap, outcome.gone);
|
|
21788
|
+
else later(SWAP_SLOW_POLL_MS);
|
|
21789
|
+
continue;
|
|
21790
|
+
} else {
|
|
21791
|
+
await this.dropSwap(swap, outcome.gone, !outcome.final);
|
|
21792
|
+
await this.tellSwap(swap, false);
|
|
21793
|
+
if (!outcome.final) later(SWAP_SLOW_POLL_MS);
|
|
21794
|
+
}
|
|
21795
|
+
this.events.onChange();
|
|
21796
|
+
} catch {
|
|
21797
|
+
if (swap.released && Date.now() >= swap.createdAt + SWAP_WATCH_MS) await this.dropSwap(swap, /* @__PURE__ */ new Set()).catch(() => {});
|
|
21798
|
+
else later(swap.released ? SWAP_SLOW_POLL_MS : SWAP_POLL_MS);
|
|
21799
|
+
}
|
|
21800
|
+
}).catch(() => {
|
|
21801
|
+
later(SWAP_POLL_MS);
|
|
21802
|
+
});
|
|
21803
|
+
if (again && !mints) this.swapsAgainIn(again);
|
|
21804
|
+
}
|
|
20221
21805
|
/** What paying this invoice would cost, from the first mint that can afford it. */
|
|
20222
21806
|
async quoteInvoice(invoice, network) {
|
|
20223
21807
|
let lastError = /* @__PURE__ */ new Error("Add a mint in Settings first");
|
|
@@ -20248,7 +21832,8 @@ var CashuWallet = class {
|
|
|
20248
21832
|
/**
|
|
20249
21833
|
* Pays a melt quote. True once paid; false while the mint holds the payment pending, in which case its
|
|
20250
21834
|
* proofs stay reserved and the wallet keeps asking until the mint settles it (`onMeltResolved`).
|
|
20251
|
-
* Throws only when the sats never left the wallet or are known to be back in it
|
|
21835
|
+
* Throws only when the sats never left the wallet or are known to be back in it, or when the split before the payment
|
|
21836
|
+
* has no answer yet (`SwapUnsettledError`): the payment was not made, and the split is settled by `pollSwaps`.
|
|
20252
21837
|
*/
|
|
20253
21838
|
async payQuote(quoteId, mint, note, paymentId) {
|
|
20254
21839
|
try {
|
|
@@ -20265,13 +21850,15 @@ var CashuWallet = class {
|
|
|
20265
21850
|
const quote = await wallet.checkMeltQuoteBolt11(quoteId);
|
|
20266
21851
|
if ((await wrap((await store(STORES.melts, "readonly")).getAll())).some((m) => m.quote === quote.quote || m.request === quote.request)) throw new Error("This invoice is already being paid");
|
|
20267
21852
|
const needed = quote.amount.toNumber() + quote.fee_reserve.toNumber();
|
|
20268
|
-
const
|
|
20269
|
-
const { keep, send } = await wallet.send(needed, asProofLike(proofs), { includeFees: true });
|
|
21853
|
+
const { inputs, keep, send, swap } = await this.split(mint, needed, await this.proofsAt(mint));
|
|
20270
21854
|
let preview;
|
|
20271
21855
|
try {
|
|
20272
21856
|
preview = await wallet.prepareMelt("bolt11", quote, send);
|
|
20273
21857
|
} catch (error) {
|
|
20274
|
-
await
|
|
21858
|
+
await transact([STORES.proofs, STORES.swaps], (stores) => {
|
|
21859
|
+
this.queueReplace(stores[STORES.proofs], mint, inputs, [...keep, ...send]);
|
|
21860
|
+
if (swap) stores[STORES.swaps].delete(swap.id);
|
|
21861
|
+
});
|
|
20275
21862
|
throw error;
|
|
20276
21863
|
}
|
|
20277
21864
|
const melt = {
|
|
@@ -20280,16 +21867,21 @@ var CashuWallet = class {
|
|
|
20280
21867
|
request: quote.request,
|
|
20281
21868
|
amount: quote.amount.toNumber(),
|
|
20282
21869
|
secrets: send.map((p) => p.secret),
|
|
20283
|
-
outlay: total(
|
|
21870
|
+
outlay: total(inputs) - sats(keep),
|
|
20284
21871
|
outputs: preview.outputData.map((o) => OutputData.serialize(o)),
|
|
20285
21872
|
note: note?.slice(0, 140),
|
|
20286
21873
|
paymentId,
|
|
20287
21874
|
createdAt: Date.now()
|
|
20288
21875
|
};
|
|
20289
|
-
await transact([
|
|
20290
|
-
|
|
21876
|
+
await transact([
|
|
21877
|
+
STORES.proofs,
|
|
21878
|
+
STORES.melts,
|
|
21879
|
+
STORES.swaps
|
|
21880
|
+
], (stores) => {
|
|
21881
|
+
this.queueReplace(stores[STORES.proofs], mint, inputs, keep);
|
|
20291
21882
|
for (const p of send) stores[STORES.proofs].put(toStored(mint, p, true));
|
|
20292
21883
|
stores[STORES.melts].put(melt);
|
|
21884
|
+
if (swap) stores[STORES.swaps].delete(swap.id);
|
|
20293
21885
|
});
|
|
20294
21886
|
let result;
|
|
20295
21887
|
try {
|
|
@@ -20299,6 +21891,7 @@ var CashuWallet = class {
|
|
|
20299
21891
|
const outcome = await this.settleMelt(melt).catch(() => "pending");
|
|
20300
21892
|
if (outcome === "unpaid") {
|
|
20301
21893
|
const lost = this.takeFailedFee(melt.quote);
|
|
21894
|
+
if (this.spentAway.delete(melt.quote)) throw Object.assign(engineError("ecashAlreadySpent"), { cause: error });
|
|
20302
21895
|
if (lost > 0) throw Object.assign(/* @__PURE__ */ new Error(`${error instanceof Error ? error.message : String(error)} ${backInWallet(lost)}`), { cause: error });
|
|
20303
21896
|
throw error;
|
|
20304
21897
|
}
|
|
@@ -20310,6 +21903,13 @@ var CashuWallet = class {
|
|
|
20310
21903
|
}
|
|
20311
21904
|
return this.settleMelt(melt).catch(() => "pending");
|
|
20312
21905
|
}
|
|
21906
|
+
/**
|
|
21907
|
+
* Whether a Lightning payment of this invoice is written down as in flight. A melt is written before the mint sees
|
|
21908
|
+
* its proofs, and `pollMelts` settles and reports it: an invoice with none never reached the mint's melt, or is done.
|
|
21909
|
+
*/
|
|
21910
|
+
async meltInFlight(invoice) {
|
|
21911
|
+
return (await wrap((await store(STORES.melts, "readonly")).getAll())).some((m) => m.request === invoice);
|
|
21912
|
+
}
|
|
20313
21913
|
/** Lightning payments left pending, asked about again until the mint settles each one. */
|
|
20314
21914
|
async pollMelts() {
|
|
20315
21915
|
if (this.meltTimer) clearTimeout(this.meltTimer);
|
|
@@ -20322,6 +21922,7 @@ var CashuWallet = class {
|
|
|
20322
21922
|
});
|
|
20323
21923
|
if (outcome === "paid" || outcome === "unpaid") {
|
|
20324
21924
|
this.takeFailedFee(melt.quote);
|
|
21925
|
+
this.spentAway.delete(melt.quote);
|
|
20325
21926
|
this.events.onMeltResolved(melt, outcome === "paid");
|
|
20326
21927
|
this.events.onChange();
|
|
20327
21928
|
}
|
|
@@ -20356,7 +21957,7 @@ var CashuWallet = class {
|
|
|
20356
21957
|
if (states.some((s) => s.state === "PENDING")) return "pending";
|
|
20357
21958
|
const spent = new Set(inputs.filter((_, i) => states[i]?.state === "SPENT").map((p) => p.secret));
|
|
20358
21959
|
if (spent.size > 0) console.warn(`[wallet] melt ${melt.quote} is unpaid, yet ${spent.size} of its proofs are spent`);
|
|
20359
|
-
const lost = melt.outlay - inputs.
|
|
21960
|
+
const lost = melt.outlay - inputs.reduce((sum, p) => sum + p.amount, 0);
|
|
20360
21961
|
await transact([
|
|
20361
21962
|
STORES.proofs,
|
|
20362
21963
|
STORES.melts,
|
|
@@ -20371,6 +21972,10 @@ var CashuWallet = class {
|
|
|
20371
21972
|
if (lost > 0) stores[STORES.walletTx].put(walletTx(melt.mint, "fee", 0, lost, melt.note ? `Payment failed: ${melt.note}` : "A Lightning payment failed"));
|
|
20372
21973
|
});
|
|
20373
21974
|
if (lost > 0) this.failedFees.set(melt.quote, lost);
|
|
21975
|
+
if (spent.size > 0) {
|
|
21976
|
+
this.spentAway.add(melt.quote);
|
|
21977
|
+
this.recheck(melt.mint).catch(() => {});
|
|
21978
|
+
}
|
|
20374
21979
|
return "unpaid";
|
|
20375
21980
|
}
|
|
20376
21981
|
/** The fee a failed melt cost, once: for the words that say it failed. */
|
|
@@ -20399,14 +22004,22 @@ var CashuWallet = class {
|
|
|
20399
22004
|
* and every invoice of theirs are deleted. A Lightning payment still in flight from one of them is not cut off: the
|
|
20400
22005
|
* removal is refused until it settles.
|
|
20401
22006
|
*/
|
|
20402
|
-
async forget(mints) {
|
|
22007
|
+
async forget(mints, acceptLoss = false) {
|
|
20403
22008
|
const at = new Set(mints);
|
|
20404
22009
|
if ((await wrap((await store(STORES.melts, "readonly")).getAll())).some((m) => at.has(m.mint))) throw new Error("A Lightning payment from this wallet is still in flight: wait for it to settle, then remove the wallet.");
|
|
22010
|
+
await this.pollSwaps(mints).catch(() => {});
|
|
22011
|
+
if ((await wrap((await store(STORES.swaps, "readonly")).getAll())).filter((s) => at.has(s.mint) && !s.released).length > 0 && !acceptLoss) throw new Error("An exchange of ecash with this wallet's mint is not finished yet: wait for the mint to answer, or confirm that what it would bring is lost to remove the wallet.");
|
|
20405
22012
|
const proofs = (await this.allProofs()).filter((p) => at.has(p.mint));
|
|
20406
22013
|
const quotes = (await wrap((await store(STORES.quotes, "readonly")).getAll())).filter((q) => at.has(q.mint));
|
|
20407
|
-
await
|
|
22014
|
+
const swaps = (await wrap((await store(STORES.swaps, "readonly")).getAll())).filter((s) => at.has(s.mint));
|
|
22015
|
+
await transact([
|
|
22016
|
+
STORES.proofs,
|
|
22017
|
+
STORES.quotes,
|
|
22018
|
+
STORES.swaps
|
|
22019
|
+
], (stores) => {
|
|
20408
22020
|
for (const p of proofs) stores[STORES.proofs].delete(p.secret);
|
|
20409
22021
|
for (const q of quotes) stores[STORES.quotes].delete(q.quote);
|
|
22022
|
+
for (const s of swaps) stores[STORES.swaps].delete(s.id);
|
|
20410
22023
|
});
|
|
20411
22024
|
for (const mint of mints) {
|
|
20412
22025
|
this.wallets.delete(mint);
|
|
@@ -20462,10 +22075,7 @@ var CashuWallet = class {
|
|
|
20462
22075
|
async proofsAt(mint) {
|
|
20463
22076
|
return (await this.allProofs()).filter((p) => p.mint === mint && !p.reserved);
|
|
20464
22077
|
}
|
|
20465
|
-
/** After a swap: the inputs are spent, `keep` is what came back to us.
|
|
20466
|
-
async replaceProofs(mint, inputs, keep) {
|
|
20467
|
-
await transact([STORES.proofs], (stores) => this.queueReplace(stores[STORES.proofs], mint, inputs, keep));
|
|
20468
|
-
}
|
|
22078
|
+
/** After a swap: the inputs are spent, `keep` is what came back to us. Queued in the caller's transaction: all or nothing. */
|
|
20469
22079
|
queueReplace(proofStore, mint, inputs, keep) {
|
|
20470
22080
|
const kept = new Set(keep.map((p) => p.secret));
|
|
20471
22081
|
for (const p of inputs) if (!kept.has(p.secret)) proofStore.delete(p.secret);
|
|
@@ -21147,7 +22757,11 @@ var PaymentDesk = class {
|
|
|
21147
22757
|
}
|
|
21148
22758
|
if (!payment?.token || payment.direction !== "out") throw new Error("Nothing to reclaim");
|
|
21149
22759
|
try {
|
|
21150
|
-
await this.wallet.receiveToken(payment.token, "reclaimed", payment.memo)
|
|
22760
|
+
await this.wallet.receiveToken(payment.token, "reclaimed", payment.memo, { payment: () => ({
|
|
22761
|
+
...this.current(payment),
|
|
22762
|
+
state: "reclaimed",
|
|
22763
|
+
token: void 0
|
|
22764
|
+
}) });
|
|
21151
22765
|
await this.save({
|
|
21152
22766
|
...this.current(payment),
|
|
21153
22767
|
state: "reclaimed",
|
|
@@ -21458,31 +23072,18 @@ var PaymentDesk = class {
|
|
|
21458
23072
|
addTestMint: true,
|
|
21459
23073
|
payment: record
|
|
21460
23074
|
});
|
|
21461
|
-
await this.
|
|
21462
|
-
|
|
21463
|
-
if (
|
|
21464
|
-
|
|
21465
|
-
|
|
21466
|
-
|
|
21467
|
-
|
|
21468
|
-
|
|
23075
|
+
await this.creditCashu(record(amount, mint), link);
|
|
23076
|
+
} catch (error) {
|
|
23077
|
+
if (error instanceof SwapUnsettledError) return;
|
|
23078
|
+
const settled = this.payments.get(payment.id);
|
|
23079
|
+
if (settled && settled.linkId === linkId && settled.direction === "in" && settled.kind === "payment" && settled.state === "settled") {
|
|
23080
|
+
link?.sendPaymentResult({
|
|
23081
|
+
id: payment.id,
|
|
23082
|
+
ok: true,
|
|
23083
|
+
credited: String(settled.amount)
|
|
21469
23084
|
});
|
|
23085
|
+
return;
|
|
21470
23086
|
}
|
|
21471
|
-
await this.host.storeMessage({
|
|
21472
|
-
linkId,
|
|
21473
|
-
id: `peer_${payment.timestamp}`,
|
|
21474
|
-
text: `⚡ ${amount.toLocaleString()} ${isWorthlessMint(mint) ? "test sats" : "sats"}`,
|
|
21475
|
-
sender: "peer",
|
|
21476
|
-
timestamp: payment.timestamp,
|
|
21477
|
-
via: "datalink",
|
|
21478
|
-
paymentId: payment.id
|
|
21479
|
-
});
|
|
21480
|
-
link?.sendPaymentResult({
|
|
21481
|
-
id: payment.id,
|
|
21482
|
-
ok: true,
|
|
21483
|
-
credited: String(amount)
|
|
21484
|
-
});
|
|
21485
|
-
} catch (error) {
|
|
21486
23087
|
const reason = error instanceof Error ? error.message : String(error);
|
|
21487
23088
|
link?.sendPaymentResult({
|
|
21488
23089
|
id: payment.id,
|
|
@@ -21502,6 +23103,61 @@ var PaymentDesk = class {
|
|
|
21502
23103
|
} catch {}
|
|
21503
23104
|
}
|
|
21504
23105
|
}
|
|
23106
|
+
/**
|
|
23107
|
+
* A contact's ecash is in the wallet, and its record was written with it in the same transaction: the request it
|
|
23108
|
+
* pays is settled, the chat shows it, and the contact is told.
|
|
23109
|
+
*/
|
|
23110
|
+
async creditCashu(record, link) {
|
|
23111
|
+
const { linkId, amount } = record;
|
|
23112
|
+
const mint = record.mint;
|
|
23113
|
+
await this.save(record);
|
|
23114
|
+
const request = record.requestId ? this.payments.get(record.requestId) : void 0;
|
|
23115
|
+
if (request?.kind === "request" && request.direction === "out" && this.owns(request, linkId) && request.state === "pending" && amount >= request.amount && (request.mints ?? []).includes(mint) && (!isWorthlessMint(mint) || (request.mints ?? []).every(isWorthlessMint))) {
|
|
23116
|
+
if (request.group) await this.settleRequest(request, { mint });
|
|
23117
|
+
else await this.save({
|
|
23118
|
+
...request,
|
|
23119
|
+
state: "settled",
|
|
23120
|
+
mint
|
|
23121
|
+
});
|
|
23122
|
+
}
|
|
23123
|
+
await this.host.storeMessage({
|
|
23124
|
+
linkId,
|
|
23125
|
+
id: `peer_${record.createdAt}`,
|
|
23126
|
+
text: `⚡ ${amount.toLocaleString()} ${isWorthlessMint(mint) ? "test sats" : "sats"}`,
|
|
23127
|
+
sender: "peer",
|
|
23128
|
+
timestamp: record.createdAt,
|
|
23129
|
+
via: "datalink",
|
|
23130
|
+
paymentId: record.id
|
|
23131
|
+
});
|
|
23132
|
+
link?.sendPaymentResult({
|
|
23133
|
+
id: record.id,
|
|
23134
|
+
ok: true,
|
|
23135
|
+
credited: String(amount)
|
|
23136
|
+
});
|
|
23137
|
+
}
|
|
23138
|
+
/**
|
|
23139
|
+
* A redeem the mint had not answered is settled, after the call that made it was over (`CashuWallet.pollSwaps`).
|
|
23140
|
+
* Done: the ecash and its payment record are already stored, and the rest follows as if the answer had come in time.
|
|
23141
|
+
* Not done: the mint never made it, so a contact's ecash is still the contact's, and it is told so.
|
|
23142
|
+
*/
|
|
23143
|
+
async onSwapSettled(swap, done) {
|
|
23144
|
+
const record = swap.payment;
|
|
23145
|
+
if (!record) return;
|
|
23146
|
+
const link = this.host.getLink(record.linkId);
|
|
23147
|
+
if (record.direction === "out") {
|
|
23148
|
+
if (done) {
|
|
23149
|
+
this.payments.set(record.id, record);
|
|
23150
|
+
this.host.onChange();
|
|
23151
|
+
}
|
|
23152
|
+
return;
|
|
23153
|
+
}
|
|
23154
|
+
if (done) await this.creditCashu(record, link);
|
|
23155
|
+
else if (!this.payments.has(record.id)) link?.sendPaymentResult({
|
|
23156
|
+
id: record.id,
|
|
23157
|
+
ok: false,
|
|
23158
|
+
error: "The mint did not confirm the ecash"
|
|
23159
|
+
});
|
|
23160
|
+
}
|
|
21505
23161
|
async onPaymentResult(linkId, result) {
|
|
21506
23162
|
const payment = this.find(linkId, result.id);
|
|
21507
23163
|
if (payment?.target?.method === "fedimint" && payment.linkId === linkId && payment.direction === "out" && payment.kind === "payment") {
|
|
@@ -23019,6 +24675,20 @@ function knockCue(before, after) {
|
|
|
23019
24675
|
}
|
|
23020
24676
|
/** A group's own line that says I made it or joined it (not someone else joining). */
|
|
23021
24677
|
var groupCue = (message) => message.event === "created" || message.event === "joined" && !message.member ? "group" : void 0;
|
|
24678
|
+
/**
|
|
24679
|
+
* When a contact's message was written, by this device's clock. The time it says is its sender's clock, and a clock
|
|
24680
|
+
* behind this one makes everything it writes look old: a reply to what I just wrote read as written minutes before
|
|
24681
|
+
* my message, a late catch-up, and nothing rang. `leads`: for the contact's latest messages on the live session, how
|
|
24682
|
+
* far the time each said was from when it came (`sentAt - timestamp`). A message cannot come before it is written, so
|
|
24683
|
+
* the one that came quickest says how far behind the contact's clock is, and every time it says is read that much
|
|
24684
|
+
* later. A clock ahead needs nothing: a time still to come is taken as when the message came (as `shownTime` does).
|
|
24685
|
+
* With no live message to tell the clock by, the time is taken as said.
|
|
24686
|
+
*/
|
|
24687
|
+
function writtenAt(message, leads) {
|
|
24688
|
+
if (message.sentAt === void 0) return message.timestamp;
|
|
24689
|
+
const behind = leads?.length ? Math.min(0, Math.max(...leads)) : 0;
|
|
24690
|
+
return Math.min(message.timestamp, message.sentAt - behind);
|
|
24691
|
+
}
|
|
23022
24692
|
var JOIN_NOTICE = /^👋 (?:.+ )?joined$/;
|
|
23023
24693
|
/** What a newly stored row says, or null when it is quiet. `type`: "message" for the contact's, "sent" for mine. */
|
|
23024
24694
|
function messageAttention(type, message, { startedAt, newest, now }) {
|
|
@@ -23205,7 +24875,11 @@ var WalletSetup = class {
|
|
|
23205
24875
|
* The wasm (about 1 MB gzip) loads on the first chat that starts an endpoint,
|
|
23206
24876
|
* never on the app's first load.
|
|
23207
24877
|
*/
|
|
23208
|
-
/**
|
|
24878
|
+
/**
|
|
24879
|
+
* n0's public relays, the same ones the Desktop's Iroh homes on by default. Iroh itself names them with the trailing
|
|
24880
|
+
* dot of a full domain name (`use1-1.relay.n0.iroh.link.`), and compares relay URLs as text; here they have none,
|
|
24881
|
+
* because WebKit opens no host name that ends in a dot. `BROWSER_SPELLING` is what keeps the two one relay.
|
|
24882
|
+
*/
|
|
23209
24883
|
var DEFAULT_IROH_RELAYS = [
|
|
23210
24884
|
"https://use1-1.relay.n0.iroh.link/",
|
|
23211
24885
|
"https://euc1-1.relay.n0.iroh.link/",
|
|
@@ -23213,6 +24887,12 @@ var DEFAULT_IROH_RELAYS = [
|
|
|
23213
24887
|
"https://usw1-1.relay.n0.iroh.link/"
|
|
23214
24888
|
];
|
|
23215
24889
|
/**
|
|
24890
|
+
* The spelling of every relay URL handed to the wasm Iroh (`irohRelayUrl`): the relays it homes on and a contact's
|
|
24891
|
+
* relay before a dial. One spelling, so a contact on this endpoint's relay is reached over the connection already
|
|
24892
|
+
* open to it; without the dot, so Safari can open it (a Desktop's record names its relay with the dot).
|
|
24893
|
+
*/
|
|
24894
|
+
var BROWSER_SPELLING = "plain";
|
|
24895
|
+
/**
|
|
23216
24896
|
* Why a relay URL cannot be used, or null. TLS, except on this machine (a local `iroh-relay --dev`): a page
|
|
23217
24897
|
* may not open plain WebSockets elsewhere, and the relay's operator would see traffic metadata in the clear.
|
|
23218
24898
|
*/
|
|
@@ -23310,7 +24990,7 @@ var IrohWebChannel = class {
|
|
|
23310
24990
|
async function createIrohWebEndpoint(seedB64, options = {}) {
|
|
23311
24991
|
const seed = fromBase64Url(seedB64);
|
|
23312
24992
|
if (seed.length !== 32) throw new Error("Invalid transport seed");
|
|
23313
|
-
const relays = [...options.relays?.length ? options.relays : DEFAULT_IROH_RELAYS];
|
|
24993
|
+
const relays = [...new Set((options.relays?.length ? options.relays : DEFAULT_IROH_RELAYS).map((relay) => irohRelayUrl(relay, BROWSER_SPELLING)))];
|
|
23314
24994
|
const node = await (await (options.load ?? loadIrohWasm)()).IrohNode.start(seed, relays, options.onlineMs ?? 1e4);
|
|
23315
24995
|
const channels = /* @__PURE__ */ new Set();
|
|
23316
24996
|
let stopped = false;
|
|
@@ -23340,7 +25020,7 @@ async function createIrohWebEndpoint(seedB64, options = {}) {
|
|
|
23340
25020
|
},
|
|
23341
25021
|
async connect(descriptor) {
|
|
23342
25022
|
if (stopped) throw new Error("Iroh endpoint is stopped");
|
|
23343
|
-
const conn = await node.connect(descriptor, options.connectMs ?? 2e4);
|
|
25023
|
+
const conn = await node.connect(irohDescriptor(descriptor, BROWSER_SPELLING), options.connectMs ?? 2e4);
|
|
23344
25024
|
if (stopped) {
|
|
23345
25025
|
conn.close();
|
|
23346
25026
|
throw new Error("Iroh endpoint is stopped");
|
|
@@ -23448,6 +25128,8 @@ var SPARE_INVITE_WARM_AFTER_TAKE_MS = 3e4;
|
|
|
23448
25128
|
*/
|
|
23449
25129
|
var STARTUP_QUIET_MS = 15e3;
|
|
23450
25130
|
var SPARE_INVITE_MAX_AGE_MS = 9e5;
|
|
25131
|
+
/** How far before this clock a warm packet is dated (`warmKey`): further than a contact's clock is ever behind. */
|
|
25132
|
+
var WARM_DATED_BACK_MS = 864e5;
|
|
23451
25133
|
/** How long a removal waits for a wallet's rail to claim what was already paid to it (tests shorten it). */
|
|
23452
25134
|
var removalTiming = { claimMs: 3e4 };
|
|
23453
25135
|
/** Why a removal needs the person's confirmation: what it holds, what it still waits for, and what confirming means. */
|
|
@@ -23520,12 +25202,34 @@ var GhostlyNode = class GhostlyNode {
|
|
|
23520
25202
|
relays;
|
|
23521
25203
|
/** Other profiles of this device, looked at for new messages (WISP 04 § Checking other profiles). */
|
|
23522
25204
|
profilePeek;
|
|
25205
|
+
/** Whether this device's WebRTC attempts say direct connections are blocked on its network (`directPath.ts`). */
|
|
25206
|
+
directPath = new DirectPathWatch(() => {
|
|
25207
|
+
if (!this.shuttingDown) this.emitState();
|
|
25208
|
+
});
|
|
25209
|
+
/** Whether this device's clock seems to be off, from what the relays' answers and several contacts' packets say (`clockWatch.ts`). */
|
|
25210
|
+
clock = new ClockWatch(() => {
|
|
25211
|
+
if (!this.shuttingDown) this.emitState();
|
|
25212
|
+
});
|
|
25213
|
+
clockOff;
|
|
25214
|
+
/**
|
|
25215
|
+
* Edges of groups whose WebRTC did not connect in this run of the app (`edgeWithoutRtc`): they go on as an app with
|
|
25216
|
+
* no WebRTC does, over a native transport. Not kept: the next start tries WebRTC first again.
|
|
25217
|
+
*/
|
|
25218
|
+
edgeRtcOff = /* @__PURE__ */ new Set();
|
|
23523
25219
|
pollIntervals;
|
|
23524
25220
|
localFetch;
|
|
23525
25221
|
links = /* @__PURE__ */ new Map();
|
|
23526
25222
|
services = [];
|
|
23527
25223
|
activeLinkId = null;
|
|
23528
|
-
|
|
25224
|
+
/**
|
|
25225
|
+
* Native listeners start one at a time per transport (`ensureNativeEndpoints`): their slots are counted per transport.
|
|
25226
|
+
* Not one queue for all: a transport slow to start held every other chat's listeners of every kind behind it.
|
|
25227
|
+
*/
|
|
25228
|
+
nativeQueues = /* @__PURE__ */ new Map();
|
|
25229
|
+
/** Every listener start asked for so far has ended. */
|
|
25230
|
+
get nativeQueue() {
|
|
25231
|
+
return Promise.all(this.nativeQueues.values()).then(() => {});
|
|
25232
|
+
}
|
|
23529
25233
|
shuttingDown = false;
|
|
23530
25234
|
/** `start` has put the kept transfers back (EngineState.transfersRestored). */
|
|
23531
25235
|
transfersRestored = false;
|
|
@@ -23551,11 +25255,26 @@ var GhostlyNode = class GhostlyNode {
|
|
|
23551
25255
|
cueFeedback({ cue, key }, linkId) {
|
|
23552
25256
|
this.feedback("cue", cue + ":" + key, linkId, false, cue);
|
|
23553
25257
|
}
|
|
23554
|
-
/** The newest time
|
|
25258
|
+
/** The newest time written in each chat while this engine runs: a message written well before it is a catch-up. */
|
|
23555
25259
|
newestAt = /* @__PURE__ */ new Map();
|
|
23556
|
-
/**
|
|
25260
|
+
/** The last place given in each chat while this engine runs (`arrivalKey`). */
|
|
25261
|
+
placedAt = /* @__PURE__ */ new Map();
|
|
25262
|
+
/**
|
|
25263
|
+
* Each 1:1 chat's contact: how far the time its latest live messages said was from when they came (`CLOCK_SAMPLES`
|
|
25264
|
+
* of them). What tells a contact's clock that is behind from a message that is late (`writtenAt`).
|
|
25265
|
+
*/
|
|
25266
|
+
clockLeads = /* @__PURE__ */ new Map();
|
|
25267
|
+
/** A contact's row came on the live session of a 1:1 chat: what it says of the contact's clock is kept. */
|
|
25268
|
+
clockSample(message) {
|
|
25269
|
+
if (message.sender !== "peer" || message.sentAt === void 0 || message.event || message.via !== "datalink" || message.linkId.startsWith("group:")) return;
|
|
25270
|
+
this.clockLeads.set(message.linkId, [...this.clockLeads.get(message.linkId) ?? [], message.sentAt - message.timestamp].slice(-5));
|
|
25271
|
+
}
|
|
25272
|
+
/** Only a new message, or mine going out, says anything (engine/attention.ts): judged by when it was written. */
|
|
23557
25273
|
messageFeedback(type, message, newest = this.newestAt.get(message.linkId)) {
|
|
23558
|
-
const attention = messageAttention(type,
|
|
25274
|
+
const attention = messageAttention(type, {
|
|
25275
|
+
...message,
|
|
25276
|
+
timestamp: writtenAt(message, this.clockLeads.get(message.linkId))
|
|
25277
|
+
}, {
|
|
23559
25278
|
startedAt: this.feedbackStartedAt,
|
|
23560
25279
|
newest,
|
|
23561
25280
|
now: Date.now()
|
|
@@ -23606,6 +25325,11 @@ var GhostlyNode = class GhostlyNode {
|
|
|
23606
25325
|
deleted: (linkId, messageId) => !!this.links.get(linkId)?.stored.deletedIds?.includes(messageId),
|
|
23607
25326
|
messageExists: (linkId, messageId) => db.hasMessage(linkId, messageId),
|
|
23608
25327
|
storeMessage: (message) => this.storeMessage(message),
|
|
25328
|
+
place: (linkId) => {
|
|
25329
|
+
const place = arrivalKey(Date.now(), this.placedAt.get(linkId));
|
|
25330
|
+
this.placedAt.set(linkId, place);
|
|
25331
|
+
return place;
|
|
25332
|
+
},
|
|
23609
25333
|
transfers: this.transfers,
|
|
23610
25334
|
changed: (delayMs) => this.emitState(delayMs),
|
|
23611
25335
|
flush: () => this.flushState(),
|
|
@@ -23772,7 +25496,8 @@ var GhostlyNode = class GhostlyNode {
|
|
|
23772
25496
|
paymentId: melt.paymentId,
|
|
23773
25497
|
mint: melt.mint
|
|
23774
25498
|
}),
|
|
23775
|
-
onTestMintNeeded: async (mint) => void await this.walletAddMint({ url: mint })
|
|
25499
|
+
onTestMintNeeded: async (mint) => void await this.walletAddMint({ url: mint }),
|
|
25500
|
+
onSwapSettled: (swap, done) => this.desk.onSwapSettled(swap, done).then(() => swap.payment?.direction === "out" ? this.reviewTakenBack(swap.payment.id) : void 0)
|
|
23776
25501
|
}, () => this.settings.mints);
|
|
23777
25502
|
registry;
|
|
23778
25503
|
providers() {
|
|
@@ -23827,7 +25552,7 @@ var GhostlyNode = class GhostlyNode {
|
|
|
23827
25552
|
const stored = this.links.get(linkId)?.stored;
|
|
23828
25553
|
return stored?.group && !stored.groupEntry ? stored.group : parsePayLink(linkId)?.groupId;
|
|
23829
25554
|
},
|
|
23830
|
-
groupLinks: (groupId) => this.groups.isCommunityGroup(groupId) ? [groupLinkId(groupId)] : [...this.
|
|
25555
|
+
groupLinks: (groupId) => this.groups.isCommunityGroup(groupId) ? [groupLinkId(groupId)] : [...this.memberEdges(groupId).values()],
|
|
23831
25556
|
storeMessage: (message) => this.storeMessage(message),
|
|
23832
25557
|
heldPaymentMethods: (linkId) => {
|
|
23833
25558
|
const live = this.links.get(linkId);
|
|
@@ -24253,11 +25978,14 @@ var GhostlyNode = class GhostlyNode {
|
|
|
24253
25978
|
/** Private groups (WISP 900): sessions, admission on contact chats, and the pairwise edges that carry them. */
|
|
24254
25979
|
groups = new Groups({
|
|
24255
25980
|
sendOnLink: (linkId, frame) => {
|
|
24256
|
-
const
|
|
24257
|
-
if (!link) throw new Error("You are offline");
|
|
24258
|
-
|
|
25981
|
+
const live = this.links.get(linkId);
|
|
25982
|
+
if (!live?.link) throw new Error("You are offline");
|
|
25983
|
+
const { group, groupPeer, groupEntry } = live.stored;
|
|
25984
|
+
if (group && groupPeer && !groupEntry && !this.groups.edgeAllows(group, groupPeer, frame)) return;
|
|
25985
|
+
live.link.sendGroupFrame(frame);
|
|
24259
25986
|
},
|
|
24260
25987
|
linkReady: (linkId, version = 1) => !!this.links.get(linkId)?.link?.supportsGroupVersion(version),
|
|
25988
|
+
linkOpen: (linkId) => !!this.links.get(linkId)?.link?.isDataLinkOpen,
|
|
24261
25989
|
myNick: () => this.sharedNick,
|
|
24262
25990
|
staysOnline: () => this.options.staysOnline ?? (this.options.platform === "desktop" && typeof RTCPeerConnection !== "undefined"),
|
|
24263
25991
|
peerRoom: (groupId) => {
|
|
@@ -24289,7 +26017,7 @@ var GhostlyNode = class GhostlyNode {
|
|
|
24289
26017
|
},
|
|
24290
26018
|
linkBack: (linkId) => {
|
|
24291
26019
|
const live = this.links.get(linkId);
|
|
24292
|
-
return !!live?.lastSyncAt && !!live.presence?.online && live.presence
|
|
26020
|
+
return !!live?.lastSyncAt && !!live.presence?.online && presenceSeenAt(live.presence) > live.lastSyncAt;
|
|
24293
26021
|
},
|
|
24294
26022
|
publish: (identity, records, background) => this.groupTransport.publish(identity, records, { background }),
|
|
24295
26023
|
resolve: async (pubKeyZ32, background, door) => (await this.groupTransport.resolve(pubKeyZ32, {
|
|
@@ -24376,7 +26104,7 @@ var GhostlyNode = class GhostlyNode {
|
|
|
24376
26104
|
}
|
|
24377
26105
|
/** The link payments with `linkId` go over: a chat's or an edge's, or a community member's through the group. */
|
|
24378
26106
|
paymentLink(linkId) {
|
|
24379
|
-
return this.links.get(linkId)?.link ?? this.communityPay.link(linkId);
|
|
26107
|
+
return this.outsideEdge(linkId) ? null : this.links.get(linkId)?.link ?? this.communityPay.link(linkId);
|
|
24380
26108
|
}
|
|
24381
26109
|
/**
|
|
24382
26110
|
* Payments in groups (WISP 9xx § Payments): the money goes over the edge to one member through the desk, like a
|
|
@@ -24395,7 +26123,7 @@ var GhostlyNode = class GhostlyNode {
|
|
|
24395
26123
|
member: stored.groupPeer
|
|
24396
26124
|
} : void 0;
|
|
24397
26125
|
},
|
|
24398
|
-
edges: (groupId) => this.groups.isCommunityGroup(groupId) ? /* @__PURE__ */ new Map([["*", groupLinkId(groupId)]]) : this.
|
|
26126
|
+
edges: (groupId) => this.groups.isCommunityGroup(groupId) ? /* @__PURE__ */ new Map([["*", groupLinkId(groupId)]]) : this.memberEdges(groupId),
|
|
24399
26127
|
membership: (groupId) => this.membership(groupId),
|
|
24400
26128
|
send: (linkId, frame) => {
|
|
24401
26129
|
const pay = parsePayLink(linkId);
|
|
@@ -24409,17 +26137,56 @@ var GhostlyNode = class GhostlyNode {
|
|
|
24409
26137
|
},
|
|
24410
26138
|
messages: (groupId) => db.getMessages(`group:${groupId}`),
|
|
24411
26139
|
putMessage: async (message) => {
|
|
24412
|
-
await db.
|
|
26140
|
+
const kept = await db.getMessage(message.linkId, message.id);
|
|
26141
|
+
await db.putMessage(kept ? {
|
|
26142
|
+
...message,
|
|
26143
|
+
timestamp: kept.timestamp,
|
|
26144
|
+
...kept.sentAt !== void 0 && { sentAt: kept.sentAt }
|
|
26145
|
+
} : this.placed(message));
|
|
24413
26146
|
await this.messagesChanged(message.linkId, [message.id]);
|
|
24414
26147
|
this.emitState();
|
|
24415
26148
|
}
|
|
24416
26149
|
});
|
|
24417
|
-
/**
|
|
26150
|
+
/**
|
|
26151
|
+
* Member key → edge link id, for the edges of a group that exist (open or not), whoever is at the other end: an edge
|
|
26152
|
+
* outlives its member's place in the roster for a moment, or for as long as it is kept to tell it (`StoredGroup.farewells`).
|
|
26153
|
+
* For the group engine alone, which opens and closes them. Whatever is said to the group goes over `memberEdges`.
|
|
26154
|
+
*/
|
|
24418
26155
|
groupEdges(groupId) {
|
|
24419
26156
|
const edges = /* @__PURE__ */ new Map();
|
|
24420
26157
|
for (const live of this.links.values()) if (live.stored.group === groupId && live.stored.groupPeer && !live.stored.groupEntry) edges.set(live.stored.groupPeer, live.stored.id);
|
|
24421
26158
|
return edges;
|
|
24422
26159
|
}
|
|
26160
|
+
/** The edges of a private group to its members as the roster has them now: the only ones the group's traffic goes over. */
|
|
26161
|
+
memberEdges(groupId) {
|
|
26162
|
+
const edges = this.groupEdges(groupId);
|
|
26163
|
+
for (const key of [...edges.keys()]) if (!this.groups.inRoster(groupId, key)) edges.delete(key);
|
|
26164
|
+
return edges;
|
|
26165
|
+
}
|
|
26166
|
+
/** An edge of a private group to someone its roster no longer has: it says no name and no picture of mine. */
|
|
26167
|
+
quietEdge(stored) {
|
|
26168
|
+
return !!stored && this.meshEdge(stored) && this.groups.outsider(stored.group, stored.groupPeer);
|
|
26169
|
+
}
|
|
26170
|
+
/** Edges that said no name while their other end was out of the roster: the name is said again once it is back in. */
|
|
26171
|
+
quietEdges = /* @__PURE__ */ new Set();
|
|
26172
|
+
/** A group's roster changed: an edge kept to someone now out of it (closed, waiting for them) stops carrying my name. */
|
|
26173
|
+
quietOutsideEdges(groupId) {
|
|
26174
|
+
for (const live of this.links.values()) {
|
|
26175
|
+
if (live.stored.group !== groupId || !live.link || !this.meshEdge(live.stored)) continue;
|
|
26176
|
+
const id = live.stored.id;
|
|
26177
|
+
if (this.quietEdge(live.stored)) {
|
|
26178
|
+
if (!this.quietEdges.has(id) && !live.link.isDataLinkOpen) {
|
|
26179
|
+
this.quietEdges.add(id);
|
|
26180
|
+
live.link.setNick(void 0);
|
|
26181
|
+
}
|
|
26182
|
+
} else if (this.quietEdges.delete(id)) live.link.setNick(this.sharedNick);
|
|
26183
|
+
}
|
|
26184
|
+
}
|
|
26185
|
+
/** An edge of a private group whose other end is not in its roster: nothing of the group goes over it but what the group engine says. */
|
|
26186
|
+
outsideEdge(linkId) {
|
|
26187
|
+
const stored = this.links.get(linkId)?.stored;
|
|
26188
|
+
return !!stored && this.meshEdge(stored) && !this.groups.inRoster(stored.group, stored.groupPeer);
|
|
26189
|
+
}
|
|
24423
26190
|
constructor(events, options = {}) {
|
|
24424
26191
|
this.events = events;
|
|
24425
26192
|
this.options = options;
|
|
@@ -24436,6 +26203,7 @@ var GhostlyNode = class GhostlyNode {
|
|
|
24436
26203
|
this.localFetch = options.localFetch ?? webLocalFetch;
|
|
24437
26204
|
this.hold.host.transport = this.transport;
|
|
24438
26205
|
this.transport.subscribe?.(() => this.emitState());
|
|
26206
|
+
this.clockOff = this.transport.onServerTime?.((time) => this.clock.server(time.source, time.date, time.sent, time.received));
|
|
24439
26207
|
}
|
|
24440
26208
|
/** The Desktop reaches the DHT itself: the relays in Settings are written to, and read from only when the person chose so. */
|
|
24441
26209
|
configureDirect() {
|
|
@@ -24519,6 +26287,10 @@ var GhostlyNode = class GhostlyNode {
|
|
|
24519
26287
|
networks[network] = {
|
|
24520
26288
|
mints: this.withWaits(view.mints, awaiting.mints),
|
|
24521
26289
|
balance: view.balance,
|
|
26290
|
+
setAside: view.setAside,
|
|
26291
|
+
openSwaps: view.openSwaps,
|
|
26292
|
+
swapsAmount: view.swapsAmount,
|
|
26293
|
+
unconfirmed: view.unconfirmed,
|
|
24522
26294
|
history,
|
|
24523
26295
|
feesPaid: history.reduce((sum, tx) => sum + tx.fee, 0),
|
|
24524
26296
|
ark: this.arkWallets[network].view,
|
|
@@ -24659,6 +26431,7 @@ var GhostlyNode = class GhostlyNode {
|
|
|
24659
26431
|
});
|
|
24660
26432
|
const history = /* @__PURE__ */ new Map();
|
|
24661
26433
|
for (const stored of await db.getLinks()) {
|
|
26434
|
+
if (!stored.group) await this.settleHistory(stored.id);
|
|
24662
26435
|
const messages = stored.group ? [] : await db.getMessages(stored.id);
|
|
24663
26436
|
const storedFiles = stored.group ? [] : await fileStore.listForLink(stored.id);
|
|
24664
26437
|
const waiting = new Set(messages.flatMap((m) => m.sender === "me" && m.delivery === "waiting" && m.file ? [m.file.id] : []));
|
|
@@ -24671,11 +26444,13 @@ var GhostlyNode = class GhostlyNode {
|
|
|
24671
26444
|
const files = linkFilesFrom(stored.id, storedFiles, messages);
|
|
24672
26445
|
this.links.set(stored.id, newLiveLink(stored, messages[messages.length - 1]?.timestamp ?? 0, files));
|
|
24673
26446
|
history.set(stored.id, messages);
|
|
26447
|
+
for (const message of messages.slice(-20)) this.clockSample(message);
|
|
24674
26448
|
const note = latestReaction(messages);
|
|
24675
26449
|
if (note) this.reactionNotes.set(stored.id, note);
|
|
24676
26450
|
if (stored.profile && !stored.group) await this.outboxFor(stored.id).recover();
|
|
24677
26451
|
}
|
|
24678
26452
|
this.transfersRestored = true;
|
|
26453
|
+
for (const group of await db.getGroups()) await this.settleHistory(`group:${group.id}`);
|
|
24679
26454
|
await this.groups.load();
|
|
24680
26455
|
for (const group of this.groups.views()) {
|
|
24681
26456
|
const note = latestReaction(await db.getMessages(`group:${group.id}`));
|
|
@@ -24738,6 +26513,9 @@ var GhostlyNode = class GhostlyNode {
|
|
|
24738
26513
|
async shutdown() {
|
|
24739
26514
|
this.shuttingDown = true;
|
|
24740
26515
|
this.depart();
|
|
26516
|
+
this.directPath.close();
|
|
26517
|
+
this.clock.close();
|
|
26518
|
+
this.clockOff?.();
|
|
24741
26519
|
if (this.relayRetry) clearTimeout(this.relayRetry);
|
|
24742
26520
|
if (this.activeSlotRetry) clearTimeout(this.activeSlotRetry);
|
|
24743
26521
|
for (const timer of this.resumeTimers.values()) clearTimeout(timer);
|
|
@@ -24806,6 +26584,8 @@ var GhostlyNode = class GhostlyNode {
|
|
|
24806
26584
|
...this.transport.configure && { direct: true },
|
|
24807
26585
|
...this.transport.discovery && { discovery: this.transport.discovery() },
|
|
24808
26586
|
...typeof RTCPeerConnection === "undefined" && { webrtc: false },
|
|
26587
|
+
...this.directPath.blocked && { directBlocked: true },
|
|
26588
|
+
...this.clock.offset !== null && { clockOffMs: this.clock.offset },
|
|
24809
26589
|
...typeof RTCPeerConnection === "undefined" && !Object.keys(this.nativeFactories).length && { groupLinks: false }
|
|
24810
26590
|
},
|
|
24811
26591
|
links: [...this.links.values()].filter((live) => !live.stored.group).map((live) => this.viewOf(live)).sort((a, b) => b.createdAt - a.createdAt),
|
|
@@ -24886,6 +26666,22 @@ var GhostlyNode = class GhostlyNode {
|
|
|
24886
26666
|
before: from
|
|
24887
26667
|
});
|
|
24888
26668
|
}
|
|
26669
|
+
async statusCardIndex() {
|
|
26670
|
+
const rows = [];
|
|
26671
|
+
for (const m of await db.getCardMessages()) {
|
|
26672
|
+
if (m.card?.kind !== "task" && m.card?.kind !== "routine") continue;
|
|
26673
|
+
rows.push({
|
|
26674
|
+
linkId: m.linkId,
|
|
26675
|
+
id: m.id,
|
|
26676
|
+
card: m.card,
|
|
26677
|
+
sender: m.sender,
|
|
26678
|
+
...m.member && { member: m.member },
|
|
26679
|
+
timestamp: m.timestamp,
|
|
26680
|
+
...m.edit && { editedAt: m.edit.at }
|
|
26681
|
+
});
|
|
26682
|
+
}
|
|
26683
|
+
return rows;
|
|
26684
|
+
}
|
|
24889
26685
|
async createLink() {
|
|
24890
26686
|
const { mine, inviteCode } = this.takeInvite();
|
|
24891
26687
|
return {
|
|
@@ -24913,6 +26709,16 @@ var GhostlyNode = class GhostlyNode {
|
|
|
24913
26709
|
inviteCode: spare.inviteCode
|
|
24914
26710
|
};
|
|
24915
26711
|
}
|
|
26712
|
+
/**
|
|
26713
|
+
* Warms a key with an empty packet (`emptyLinkRecords`), dated `WARM_DATED_BACK_MS` before this clock. One of the two
|
|
26714
|
+
* keys an invite warms is the contact's: its own first packet has to be the later one, or the relays and the DHT keep
|
|
26715
|
+
* this empty one in its place. Dated by this clock, it was later than the first packets of every contact whose clock
|
|
26716
|
+
* runs behind this one: a joiner two minutes behind stayed unseen for two minutes, one an hour behind for an hour.
|
|
26717
|
+
*/
|
|
26718
|
+
warmKey(identity) {
|
|
26719
|
+
const records = emptyLinkRecords();
|
|
26720
|
+
(this.transport.publishPayload ? this.transport.publishPayload(identity.pubKeyZ32, createRelayPayload(identity, records, BigInt(Date.now() - WARM_DATED_BACK_MS) * 1000n)) : this.transport.publish(identity, records)).catch(() => {});
|
|
26721
|
+
}
|
|
24916
26722
|
makeSpare() {
|
|
24917
26723
|
const { mine, invite, inviteCode } = createChatInvite();
|
|
24918
26724
|
return {
|
|
@@ -24938,7 +26744,7 @@ var GhostlyNode = class GhostlyNode {
|
|
|
24938
26744
|
spare.warmedAt ??= Date.now();
|
|
24939
26745
|
for (const identity of [identityFromSeedB64(spare.mine.seedB64), spare.inviteKey]) {
|
|
24940
26746
|
this.warmedKeys.set(identity.pubKeyZ32, Date.now());
|
|
24941
|
-
this.
|
|
26747
|
+
this.warmKey(identity);
|
|
24942
26748
|
}
|
|
24943
26749
|
this.spareTimer = setTimeout(() => {
|
|
24944
26750
|
this.spareTimer = null;
|
|
@@ -25241,6 +27047,7 @@ var GhostlyNode = class GhostlyNode {
|
|
|
25241
27047
|
* (a group on hubs dropped it) and is no longer in the group, or a group I left or forgot, replaces it.
|
|
25242
27048
|
*/
|
|
25243
27049
|
groupMembersChanged(groupId) {
|
|
27050
|
+
this.quietOutsideEdges(groupId);
|
|
25244
27051
|
const holders = this.settings.wakeHeldBy?.[groupId];
|
|
25245
27052
|
if (!holders?.length) return;
|
|
25246
27053
|
const members = this.membership(groupId)?.members;
|
|
@@ -25255,6 +27062,7 @@ var GhostlyNode = class GhostlyNode {
|
|
|
25255
27062
|
groupHistoryGone(groupId) {
|
|
25256
27063
|
const linkId = `group:${groupId}`;
|
|
25257
27064
|
this.newestAt.delete(linkId);
|
|
27065
|
+
this.placedAt.delete(linkId);
|
|
25258
27066
|
this.reactionNotes.delete(linkId);
|
|
25259
27067
|
this.events.onMessages(linkId, []);
|
|
25260
27068
|
}
|
|
@@ -25295,7 +27103,7 @@ var GhostlyNode = class GhostlyNode {
|
|
|
25295
27103
|
async shareGroupWake(linkId) {
|
|
25296
27104
|
const live = this.links.get(linkId);
|
|
25297
27105
|
const own = this.settings.wake;
|
|
25298
|
-
if (!live?.link?.groupsSupport || !this.meshEdge(live.stored)) return;
|
|
27106
|
+
if (!live?.link?.groupsSupport || !this.meshEdge(live.stored) || this.outsideEdge(linkId)) return;
|
|
25299
27107
|
if (this.settings.wakeMutedGroups?.includes(live.stored.group)) {
|
|
25300
27108
|
this.sendGroupWake(live, null);
|
|
25301
27109
|
return;
|
|
@@ -25319,7 +27127,7 @@ var GhostlyNode = class GhostlyNode {
|
|
|
25319
27127
|
/** A `group-wake` frame on an edge: the member it is pinned to shares how to wake it, or says to forget it. */
|
|
25320
27128
|
receiveGroupWake(linkId, frame) {
|
|
25321
27129
|
const live = this.links.get(linkId);
|
|
25322
|
-
if (!live || !this.meshEdge(live.stored)) return;
|
|
27130
|
+
if (!live || !this.meshEdge(live.stored) || this.outsideEdge(linkId)) return;
|
|
25323
27131
|
let window = this.groupWakeReceived.get(linkId);
|
|
25324
27132
|
if (!window) this.groupWakeReceived.set(linkId, window = new RateWindow(6, 6e4));
|
|
25325
27133
|
if (!window.take()) return;
|
|
@@ -25338,7 +27146,7 @@ var GhostlyNode = class GhostlyNode {
|
|
|
25338
27146
|
*/
|
|
25339
27147
|
wakeMentioned(groupId, text, mentions) {
|
|
25340
27148
|
if (!mentions.length || !this.settings.online || this.groups.isCommunityGroup(groupId)) return;
|
|
25341
|
-
const edges = this.
|
|
27149
|
+
const edges = this.memberEdges(groupId);
|
|
25342
27150
|
const wakes = groupWakes({
|
|
25343
27151
|
group: groupId,
|
|
25344
27152
|
mentions,
|
|
@@ -25474,7 +27282,10 @@ var GhostlyNode = class GhostlyNode {
|
|
|
25474
27282
|
* (`network`): relays left alone for failing on the old network are asked again at once.
|
|
25475
27283
|
*/
|
|
25476
27284
|
wake(params = {}) {
|
|
25477
|
-
if (params.network)
|
|
27285
|
+
if (params.network) {
|
|
27286
|
+
this.transport.networkChanged?.();
|
|
27287
|
+
this.directPath.reset();
|
|
27288
|
+
}
|
|
25478
27289
|
for (const live of this.links.values()) live.link?.wake();
|
|
25479
27290
|
this.hold.wake();
|
|
25480
27291
|
for (const network of WALLET_NETWORKS) {
|
|
@@ -25519,7 +27330,7 @@ var GhostlyNode = class GhostlyNode {
|
|
|
25519
27330
|
};
|
|
25520
27331
|
if (!live?.link) return { error: "You are offline" };
|
|
25521
27332
|
if (!trimmed) return { error: null };
|
|
25522
|
-
const timestamp = params.timestamp ?? Date.now();
|
|
27333
|
+
const timestamp = params.timestamp ?? arrivalKey(Date.now(), this.placedAt.get(linkId));
|
|
25523
27334
|
if (live.stored.profile) live.link.setTyping(false);
|
|
25524
27335
|
if (live.stored.profile) {
|
|
25525
27336
|
const found = params.replyTo === void 0 ? void 0 : await this.replyFor(linkId, params.replyTo);
|
|
@@ -25590,6 +27401,7 @@ var GhostlyNode = class GhostlyNode {
|
|
|
25590
27401
|
async sendChatText(live, trimmed, timestamp, preview, reply, forwarded, card) {
|
|
25591
27402
|
const { link } = live, linkId = live.stored.id;
|
|
25592
27403
|
if (!link) return { error: "You are offline" };
|
|
27404
|
+
this.chatInUse(live);
|
|
25593
27405
|
const bytes = new TextEncoder().encode(trimmed).length;
|
|
25594
27406
|
if (bytes > LIMITS.maxChatMessageBytes) return {
|
|
25595
27407
|
error: `Message exceeds ${LIMITS.maxChatMessageBytes} UTF-8 bytes.`,
|
|
@@ -25841,7 +27653,7 @@ var GhostlyNode = class GhostlyNode {
|
|
|
25841
27653
|
if (!takesPeerEdit(current, edit.m) || (current.edit?.seq ?? 0) >= edit.e) return null;
|
|
25842
27654
|
const next = withEdit(current, {
|
|
25843
27655
|
seq: edit.e,
|
|
25844
|
-
at:
|
|
27656
|
+
at: heardTime(edit.ts),
|
|
25845
27657
|
text: edit.m,
|
|
25846
27658
|
preview: edit.pv,
|
|
25847
27659
|
card: edit.sc
|
|
@@ -25983,6 +27795,7 @@ var GhostlyNode = class GhostlyNode {
|
|
|
25983
27795
|
outboxFor(linkId) {
|
|
25984
27796
|
let outbox = this.outboxes.get(linkId);
|
|
25985
27797
|
if (!outbox) {
|
|
27798
|
+
const chat = this.links.get(linkId)?.stored;
|
|
25986
27799
|
outbox = new Outbox({
|
|
25987
27800
|
read: () => db.getMessages(linkId),
|
|
25988
27801
|
update: async (id, delivery, error, extra) => {
|
|
@@ -26012,10 +27825,12 @@ var GhostlyNode = class GhostlyNode {
|
|
|
26012
27825
|
}, { resender: {
|
|
26013
27826
|
ready: (message) => {
|
|
26014
27827
|
const live = this.links.get(linkId), link = live?.link;
|
|
27828
|
+
if (message.file && !message.wireId) return !!link?.isDataLinkOpen && link.textDelivery === "stream";
|
|
26015
27829
|
if (message.via === "pkarr" && !GhostlyNode.dhtByChoice(live) && link?.textDelivery !== "stream") return false;
|
|
26016
27830
|
return !!link?.canSendText && !!message.wireId && !link.validateText(message.text, message.timestamp, message.wireId, GhostlyNode.wireReply(message));
|
|
26017
27831
|
},
|
|
26018
27832
|
requeueExpired: () => !GhostlyNode.dhtByChoice(this.links.get(linkId)),
|
|
27833
|
+
...!!chat?.profile && !chat.group && { sendFile: (message) => this.sendWaitingFile(linkId, message) },
|
|
26019
27834
|
via: (message) => {
|
|
26020
27835
|
const delivery = this.links.get(linkId)?.link?.textDelivery;
|
|
26021
27836
|
return delivery === "dht" ? "pkarr" : delivery === "stream" ? "datalink" : message.via;
|
|
@@ -26390,12 +28205,19 @@ var GhostlyNode = class GhostlyNode {
|
|
|
26390
28205
|
*/
|
|
26391
28206
|
transferFile(live, file, wireId, timestamp, fail, reply, forwarded) {
|
|
26392
28207
|
const { link } = live;
|
|
26393
|
-
if (!link)
|
|
28208
|
+
if (!link) {
|
|
28209
|
+
fail("You are offline");
|
|
28210
|
+
return Promise.resolve();
|
|
28211
|
+
}
|
|
26394
28212
|
this.transfers.set(file.id, {
|
|
26395
28213
|
state: "transferring",
|
|
26396
28214
|
transferred: 0,
|
|
26397
28215
|
size: file.size
|
|
26398
28216
|
});
|
|
28217
|
+
let said = () => {};
|
|
28218
|
+
const offered = new Promise((resolve) => {
|
|
28219
|
+
said = resolve;
|
|
28220
|
+
});
|
|
26399
28221
|
(async () => {
|
|
26400
28222
|
if (!reply || !forwarded) {
|
|
26401
28223
|
const message = (await db.getMessages(live.stored.id)).find((m) => m.id === `me_${timestamp}`);
|
|
@@ -26427,6 +28249,7 @@ var GhostlyNode = class GhostlyNode {
|
|
|
26427
28249
|
size: file.size
|
|
26428
28250
|
});
|
|
26429
28251
|
const source = streamStored(stored);
|
|
28252
|
+
said();
|
|
26430
28253
|
await link.sendFile({
|
|
26431
28254
|
id: wireId,
|
|
26432
28255
|
name: file.name,
|
|
@@ -26439,7 +28262,8 @@ var GhostlyNode = class GhostlyNode {
|
|
|
26439
28262
|
...reply && { reply },
|
|
26440
28263
|
...forwarded && { forwarded }
|
|
26441
28264
|
}, source);
|
|
26442
|
-
})().catch((error) => fail(error instanceof Error ? error.message : String(error)));
|
|
28265
|
+
})().catch((error) => fail(error instanceof Error ? error.message : String(error))).finally(said);
|
|
28266
|
+
return offered;
|
|
26443
28267
|
}
|
|
26444
28268
|
fileAction({ linkId, fileId, action }) {
|
|
26445
28269
|
const live = this.links.get(linkId);
|
|
@@ -26468,13 +28292,39 @@ var GhostlyNode = class GhostlyNode {
|
|
|
26468
28292
|
return link.supportsLargeFiles;
|
|
26469
28293
|
}
|
|
26470
28294
|
/**
|
|
26471
|
-
*
|
|
26472
|
-
*
|
|
28295
|
+
* A file that waited for the chat to be live goes: said on the open session (see `transferFile`), and this returns
|
|
28296
|
+
* then, so the chat's outbox sends it in its place among the texts that waited (WISP 400, requirement 10). A file
|
|
28297
|
+
* for an app that turns out not to take files fails with that reason instead of waiting forever (WISP 03).
|
|
28298
|
+
*/
|
|
28299
|
+
async sendWaitingFile(linkId, message) {
|
|
28300
|
+
const live = this.links.get(linkId), file = message.file;
|
|
28301
|
+
if (!live?.link?.isDataLinkOpen || !file) return;
|
|
28302
|
+
if (!GhostlyNode.takesFiles(live.link)) await GhostlyNode.largeFilesAgreed(live.link);
|
|
28303
|
+
if (!live.link.isDataLinkOpen) return;
|
|
28304
|
+
if (!GhostlyNode.takesFiles(live.link)) await db.updateDelivery(linkId, message.id, "failed", "Your contact's app cannot receive files.");
|
|
28305
|
+
else {
|
|
28306
|
+
await db.putMessage(sentNow(message));
|
|
28307
|
+
await this.transferFile(live, file, file.id.slice(`${linkId}-out-`.length), message.timestamp, (error) => {
|
|
28308
|
+
this.transfers.set(file.id, {
|
|
28309
|
+
state: "failed",
|
|
28310
|
+
transferred: 0,
|
|
28311
|
+
size: file.size,
|
|
28312
|
+
error
|
|
28313
|
+
});
|
|
28314
|
+
this.emitState();
|
|
28315
|
+
}, GhostlyNode.wireReply(message));
|
|
28316
|
+
}
|
|
28317
|
+
await this.messagesChanged(linkId, [message.id]);
|
|
28318
|
+
}
|
|
28319
|
+
/**
|
|
28320
|
+
* The chat is live: what waited for it goes now, in the order it was written. A 1:1 chat's files go with its texts,
|
|
28321
|
+
* through its outbox (`sendWaitingFile`); here its payment requests, and everything of any other chat.
|
|
26473
28322
|
*/
|
|
26474
28323
|
async sendWaiting(linkId) {
|
|
26475
28324
|
const live = this.links.get(linkId);
|
|
26476
28325
|
if (!live?.link?.isDataLinkOpen) return;
|
|
26477
|
-
const
|
|
28326
|
+
const withTexts = this.outboxFor(linkId).sendsFiles;
|
|
28327
|
+
const waiting = (await db.getMessages(linkId)).filter((m) => m.sender === "me" && m.delivery === "waiting" && (m.file && !withTexts || m.paymentId)).sort((a, b) => a.timestamp - b.timestamp);
|
|
26478
28328
|
if (waiting.some((m) => m.file) && !GhostlyNode.takesFiles(live.link)) await GhostlyNode.largeFilesAgreed(live.link);
|
|
26479
28329
|
for (const message of waiting) if (message.file) {
|
|
26480
28330
|
if (!GhostlyNode.takesFiles(live.link)) {
|
|
@@ -26833,7 +28683,9 @@ var GhostlyNode = class GhostlyNode {
|
|
|
26833
28683
|
}
|
|
26834
28684
|
async createGroup({ name, profile }) {
|
|
26835
28685
|
if (typeof name !== "string" || !name.trim()) throw new Error("Give the group a name");
|
|
26836
|
-
|
|
28686
|
+
const clean = groupName(name);
|
|
28687
|
+
if (!clean) throw new Error(`A group's name is 1 to 64 characters on one line`);
|
|
28688
|
+
return { groupId: await this.groups.create(clean, profile === "mesh" ? "mesh" : "community") };
|
|
26837
28689
|
}
|
|
26838
28690
|
inviteToGroup({ groupId, linkId }) {
|
|
26839
28691
|
if (!this.links.get(linkId)?.stored.profile || this.links.get(linkId)?.stored.group) throw new Error("Invite a paired contact");
|
|
@@ -27044,7 +28896,10 @@ var GhostlyNode = class GhostlyNode {
|
|
|
27044
28896
|
if (typeof linkId !== "string" || !linkId) return { error: "No chat to react in" };
|
|
27045
28897
|
if (linkId.startsWith("group:")) {
|
|
27046
28898
|
const groupId = linkId.slice(6);
|
|
27047
|
-
if (!this.membership(groupId)) return {
|
|
28899
|
+
if (!this.membership(groupId)) return {
|
|
28900
|
+
error: this.groups.views().find((g) => g.id === groupId)?.statusReason ?? "You are not in this group",
|
|
28901
|
+
refused: true
|
|
28902
|
+
};
|
|
27048
28903
|
const result = await this.reactions.mine(linkId, messageId, emoji);
|
|
27049
28904
|
return "error" in result ? result : this.sendGroupReaction(groupId, result.reaction);
|
|
27050
28905
|
}
|
|
@@ -27131,13 +28986,14 @@ var GhostlyNode = class GhostlyNode {
|
|
|
27131
28986
|
...wireReaction(reaction),
|
|
27132
28987
|
...this.groups.signReaction(groupId, wireReaction(reaction))
|
|
27133
28988
|
};
|
|
27134
|
-
for (const edge of this.
|
|
28989
|
+
for (const edge of this.memberEdges(groupId).values()) try {
|
|
27135
28990
|
this.links.get(edge)?.link?.sendGroupFrame(frame);
|
|
27136
28991
|
} catch {}
|
|
27137
28992
|
return { error: null };
|
|
27138
28993
|
}
|
|
27139
28994
|
/** An edge of a private group opened: the member hears my latest reactions again, in case it missed them. */
|
|
27140
28995
|
async resendGroupReactions(groupId, linkId) {
|
|
28996
|
+
if (this.outsideEdge(linkId)) return;
|
|
27141
28997
|
const mine = groupReactionsToResend(await db.getMessages(`group:${groupId}`), REACTION_LIMITS.pending);
|
|
27142
28998
|
for (const reaction of mine) try {
|
|
27143
28999
|
this.links.get(linkId)?.link?.sendGroupFrame({
|
|
@@ -27197,7 +29053,7 @@ var GhostlyNode = class GhostlyNode {
|
|
|
27197
29053
|
k: frame.k,
|
|
27198
29054
|
sig: frame.sig
|
|
27199
29055
|
});
|
|
27200
|
-
for (const member of this.
|
|
29056
|
+
for (const member of this.memberEdges(groupId).keys()) this.sendGroupPinFrame(groupId, member);
|
|
27201
29057
|
return { error: null };
|
|
27202
29058
|
}
|
|
27203
29059
|
const live = this.links.get(linkId);
|
|
@@ -27282,11 +29138,11 @@ var GhostlyNode = class GhostlyNode {
|
|
|
27282
29138
|
sig: via.frame.sig
|
|
27283
29139
|
}
|
|
27284
29140
|
}) || !via) return;
|
|
27285
|
-
for (const key of this.
|
|
29141
|
+
for (const key of this.memberEdges(groupId).keys()) if (key !== via.from && key !== member) this.sendGroupPinFrame(groupId, key);
|
|
27286
29142
|
}
|
|
27287
29143
|
/** A private group's pin, as its pinner signed it, over the edge to `member`; nothing when it is down (said when it opens). */
|
|
27288
29144
|
sendGroupPinFrame(groupId, member) {
|
|
27289
|
-
const pin = this.groups.pinOf(groupId), edge = this.
|
|
29145
|
+
const pin = this.groups.pinOf(groupId), edge = this.memberEdges(groupId).get(member);
|
|
27290
29146
|
if (!pin?.k || !pin.sig || !edge || this.groups.isCommunityGroup(groupId) || !pinNumberHolds(pin.n)) return;
|
|
27291
29147
|
const frame = {
|
|
27292
29148
|
t: "group-pin",
|
|
@@ -27497,6 +29353,13 @@ var GhostlyNode = class GhostlyNode {
|
|
|
27497
29353
|
if (!WALLET_TYPES.includes(type)) throw new Error("Unknown kind of wallet");
|
|
27498
29354
|
if (network !== "mainnet" && network !== "testnet") throw new Error("Choose Mainnet or Testnet");
|
|
27499
29355
|
await this.lightnings[network].start();
|
|
29356
|
+
if (type === "cashu") {
|
|
29357
|
+
let timer;
|
|
29358
|
+
await Promise.race([this.wallet.settleSwaps(this.networkMints(network)).catch(() => {}), new Promise((resolve) => {
|
|
29359
|
+
timer = setTimeout(resolve, removalTiming.claimMs);
|
|
29360
|
+
})]);
|
|
29361
|
+
clearTimeout(timer);
|
|
29362
|
+
}
|
|
27500
29363
|
await this.refreshWallet();
|
|
27501
29364
|
const card = type === "lightning" ? asked ?? this.lightnings[network].receivingId : void 0;
|
|
27502
29365
|
const lightningName = card !== void 0 ? this.walletView.networks?.[network].lightnings?.find((c) => c.card === card)?.name : void 0;
|
|
@@ -27515,7 +29378,7 @@ var GhostlyNode = class GhostlyNode {
|
|
|
27515
29378
|
try {
|
|
27516
29379
|
if (type === "cashu") {
|
|
27517
29380
|
const mints = this.networkMints(network);
|
|
27518
|
-
await this.wallet.forget(mints);
|
|
29381
|
+
await this.wallet.forget(mints, acceptLoss === true);
|
|
27519
29382
|
await this.updateSettings({ settings: { mints: this.settings.mints.filter((m) => !mints.includes(m)) } });
|
|
27520
29383
|
} else if (type === "arkade") await this.arkWallets[network].remove();
|
|
27521
29384
|
else if (type === "bark") await this.barkWallets[network].remove();
|
|
@@ -28195,8 +30058,32 @@ var GhostlyNode = class GhostlyNode {
|
|
|
28195
30058
|
payRequest(params) {
|
|
28196
30059
|
return this.desk.payRequest(params);
|
|
28197
30060
|
}
|
|
28198
|
-
reclaimPayment({ paymentId }) {
|
|
28199
|
-
|
|
30061
|
+
async reclaimPayment({ paymentId }) {
|
|
30062
|
+
await this.desk.reclaim(paymentId);
|
|
30063
|
+
await this.reviewTakenBack(paymentId);
|
|
30064
|
+
}
|
|
30065
|
+
/**
|
|
30066
|
+
* A reviewed payment whose ecash this wallet took back: its review ends failed, at once. The mint reads that ecash
|
|
30067
|
+
* spent (this wallet redeemed it), which the review would otherwise take for the contact being paid, and a request
|
|
30068
|
+
* whose review reads paid can never be reviewed again. Only when the ecash is back for certain (`reclaimed`).
|
|
30069
|
+
*/
|
|
30070
|
+
async reviewTakenBack(id) {
|
|
30071
|
+
if (this.desk.payment(id)?.state !== "reclaimed") return;
|
|
30072
|
+
const intent = await intentRepository.get(id);
|
|
30073
|
+
if (!intent || intent.review.method !== "cashu" || ![
|
|
30074
|
+
"submitted",
|
|
30075
|
+
"unknown",
|
|
30076
|
+
"settled"
|
|
30077
|
+
].includes(intent.review.state)) return;
|
|
30078
|
+
await intentRepository.put({
|
|
30079
|
+
...intent,
|
|
30080
|
+
review: {
|
|
30081
|
+
...intent.review,
|
|
30082
|
+
state: "failed",
|
|
30083
|
+
error: engineText("paymentTakenBack")
|
|
30084
|
+
}
|
|
30085
|
+
});
|
|
30086
|
+
this.emitState();
|
|
28200
30087
|
}
|
|
28201
30088
|
addService({ name, target }) {
|
|
28202
30089
|
const cleanName = name.trim().slice(0, 48);
|
|
@@ -28332,9 +30219,11 @@ var GhostlyNode = class GhostlyNode {
|
|
|
28332
30219
|
this.publicActivity.clear();
|
|
28333
30220
|
}
|
|
28334
30221
|
}
|
|
28335
|
-
if (settings.avatar !== void 0 || settings.shareProfile !== void 0)
|
|
30222
|
+
if (settings.avatar !== void 0 || settings.shareProfile !== void 0) {
|
|
30223
|
+
for (const live of this.links.values()) if (!this.quietEdge(live.stored)) live.link?.setAvatar(this.sharedAvatar);
|
|
30224
|
+
}
|
|
28336
30225
|
if (settings.nick !== void 0 || settings.shareProfile !== void 0) {
|
|
28337
|
-
for (const live of this.links.values()) live.link?.setNick(this.sharedNick);
|
|
30226
|
+
for (const live of this.links.values()) if (!this.quietEdge(live.stored)) live.link?.setNick(this.sharedNick);
|
|
28338
30227
|
this.capsChanged();
|
|
28339
30228
|
}
|
|
28340
30229
|
if (settings.hyperdhtRelay !== void 0) {
|
|
@@ -28422,7 +30311,7 @@ var GhostlyNode = class GhostlyNode {
|
|
|
28422
30311
|
const identity = identityFromSeedB64(invite.seedB64);
|
|
28423
30312
|
if (Date.now() - (this.warmedKeys.get(identity.pubKeyZ32) ?? 0) < SPARE_INVITE_MAX_AGE_MS) return;
|
|
28424
30313
|
this.warmedKeys.set(identity.pubKeyZ32, Date.now());
|
|
28425
|
-
this.
|
|
30314
|
+
this.warmKey(identity);
|
|
28426
30315
|
} catch {}
|
|
28427
30316
|
}
|
|
28428
30317
|
/** What this peer advertises. Chat, voice and video are what Ghostly always offered. */
|
|
@@ -28460,7 +30349,9 @@ var GhostlyNode = class GhostlyNode {
|
|
|
28460
30349
|
const live = this.links.get(linkId);
|
|
28461
30350
|
if (!live?.stored.group) return;
|
|
28462
30351
|
this.links.delete(linkId);
|
|
30352
|
+
this.edgeRtcOff.delete(linkId);
|
|
28463
30353
|
this.groupWakeReceived.delete(linkId);
|
|
30354
|
+
this.quietEdges.delete(linkId);
|
|
28464
30355
|
this.groupNativeWaiting.delete(linkId);
|
|
28465
30356
|
this.groupNativeRetryAt = 0;
|
|
28466
30357
|
if (live.stored.wakeToken && this.meshEdge(live.stored)) {
|
|
@@ -28582,11 +30473,13 @@ var GhostlyNode = class GhostlyNode {
|
|
|
28582
30473
|
const { stored } = live;
|
|
28583
30474
|
const group = stored.group, peer = stored.groupPeer, entry = !!stored.groupEntry;
|
|
28584
30475
|
const role = stored.groupEntry ?? "edge";
|
|
30476
|
+
const member = () => entry || this.groups.isCommunityGroup(group) || this.groups.inRoster(group, peer);
|
|
28585
30477
|
let seen = false;
|
|
28586
30478
|
const native = this.keepsGroupNative(stored);
|
|
28587
30479
|
const resumeOn = native ? TRANSPORTS.find((t) => t !== "webrtc/1" && t in this.nativeFactories && !!stored.peerTransports?.includes(t)) : "webrtc/1";
|
|
28588
30480
|
live.pairing = { status: "connecting" };
|
|
28589
30481
|
live.link = new GhostLink({
|
|
30482
|
+
ownRecords: true,
|
|
28590
30483
|
paymentMethods: entry ? {
|
|
28591
30484
|
cashu: false,
|
|
28592
30485
|
lightning: false,
|
|
@@ -28601,7 +30494,7 @@ var GhostlyNode = class GhostlyNode {
|
|
|
28601
30494
|
usdtPaymentsSupport: !entry,
|
|
28602
30495
|
barkPaymentsSupport: !entry,
|
|
28603
30496
|
params: stored,
|
|
28604
|
-
rtcAvailable: typeof RTCPeerConnection !== "undefined",
|
|
30497
|
+
rtcAvailable: typeof RTCPeerConnection !== "undefined" && !this.edgeRtcOff.has(linkId),
|
|
28605
30498
|
resume: !entry && stored.edgeLive ? resumeOn : void 0,
|
|
28606
30499
|
native: {
|
|
28607
30500
|
peerDescriptors: stored.peerDescriptors,
|
|
@@ -28624,7 +30517,7 @@ var GhostlyNode = class GhostlyNode {
|
|
|
28624
30517
|
trustOnFirstUse: false
|
|
28625
30518
|
},
|
|
28626
30519
|
transport: this.groupTransport,
|
|
28627
|
-
nick: this.sharedNick,
|
|
30520
|
+
nick: !entry && this.quietEdge(stored) ? void 0 : this.sharedNick,
|
|
28628
30521
|
pollIntervals: entry || this.groups.isCommunityGroup(group) ? this.pollIntervals : meshEdgeIntervals(this.pollIntervals, () => this.groups.meshSize(group)),
|
|
28629
30522
|
autoConnect: true,
|
|
28630
30523
|
...entry ? {
|
|
@@ -28640,12 +30533,13 @@ var GhostlyNode = class GhostlyNode {
|
|
|
28640
30533
|
getHostedHttpService: () => void 0,
|
|
28641
30534
|
groupsSupport: true,
|
|
28642
30535
|
events: {
|
|
28643
|
-
onGroupFrame: (frame) => entry ? this.groups.handleContactFrame(linkId, frame) : frame.t === "group-pay" ? this.groupPayments.receive(group, peer, frame) : frame.t === "group-wake" ? this.receiveGroupWake(linkId, frame) : this.groups.handleEdgeFrame(group, peer, frame),
|
|
30536
|
+
onGroupFrame: (frame) => entry ? this.groups.handleContactFrame(linkId, frame) : !member() ? this.groups.handleEdgeFrame(group, peer, frame) : frame.t === "group-pay" ? this.groupPayments.receive(group, peer, frame) : frame.t === "group-wake" ? this.receiveGroupWake(linkId, frame) : this.groups.handleEdgeFrame(group, peer, frame),
|
|
28644
30537
|
onGroupsSupport: (supported) => {
|
|
28645
30538
|
if (supported) traceJoin(group, "link.ready", { role });
|
|
28646
30539
|
if (!entry) this.noteEdgeLive(linkId, supported);
|
|
28647
30540
|
if (supported) {
|
|
28648
30541
|
if (entry) this.groups.entryReady(group, linkId, peer);
|
|
30542
|
+
else if (!member()) this.groups.edgeReady(group, peer, linkId);
|
|
28649
30543
|
else {
|
|
28650
30544
|
this.groups.edgeReady(group, peer, linkId);
|
|
28651
30545
|
this.groupPayments.edgeReady(group, linkId).catch(() => {});
|
|
@@ -28670,10 +30564,10 @@ var GhostlyNode = class GhostlyNode {
|
|
|
28670
30564
|
if (this.keepsGroupNative(live.stored)) this.ensureNativeEndpoints(linkId);
|
|
28671
30565
|
},
|
|
28672
30566
|
...entry ? {} : {
|
|
28673
|
-
onPaymentRequest: (request) => this.desk.onPaymentRequest(linkId, request),
|
|
28674
|
-
onPaymentAsk: (ask) => this.desk.onPaymentAsk(linkId, ask),
|
|
28675
|
-
onPayment: (payment) => this.desk.onPayment(linkId, payment),
|
|
28676
|
-
onPaymentResult: (result) => this.desk.onPaymentResult(linkId, result),
|
|
30567
|
+
onPaymentRequest: (request) => member() ? this.desk.onPaymentRequest(linkId, request) : void 0,
|
|
30568
|
+
onPaymentAsk: (ask) => member() ? this.desk.onPaymentAsk(linkId, ask) : void 0,
|
|
30569
|
+
onPayment: (payment) => member() ? this.desk.onPayment(linkId, payment) : void 0,
|
|
30570
|
+
onPaymentResult: (result) => member() ? this.desk.onPaymentResult(linkId, result) : void 0,
|
|
28677
30571
|
onPeerNick: (nick) => this.groups.edgeNick(group, peer, nick ?? void 0)
|
|
28678
30572
|
},
|
|
28679
30573
|
onPresence: (presence) => {
|
|
@@ -28693,10 +30587,15 @@ var GhostlyNode = class GhostlyNode {
|
|
|
28693
30587
|
live.pairing = state;
|
|
28694
30588
|
this.emitState();
|
|
28695
30589
|
},
|
|
30590
|
+
onDirectEvidence: (evidence) => {
|
|
30591
|
+
this.directPath.note(peer, evidence);
|
|
30592
|
+
if (!entry && evidence !== "open" && evidence !== "closed") this.edgeWithoutRtc(linkId);
|
|
30593
|
+
},
|
|
28696
30594
|
onDataLinkState: (state) => {
|
|
28697
30595
|
traceJoin(group, `link.${state}`, { role });
|
|
28698
30596
|
if (state === "open" || live.dataLink === "open") live.lastSyncAt = Date.now();
|
|
28699
|
-
if (state === "open" && !entry) this.
|
|
30597
|
+
if (state === "open" && !entry) this.groups.edgeOpen(group, peer);
|
|
30598
|
+
if (state === "open" && !entry && member()) this.desk.replay(linkId).catch(() => {});
|
|
28700
30599
|
live.dataLink = state;
|
|
28701
30600
|
if (entry && state !== "open" && live.entryDone) this.closeGroupLink(linkId).catch(() => {});
|
|
28702
30601
|
if (state !== "open" && live.pairing?.status !== "error") live.pairing = { status: "connecting" };
|
|
@@ -28723,7 +30622,27 @@ var GhostlyNode = class GhostlyNode {
|
|
|
28723
30622
|
*/
|
|
28724
30623
|
keepsGroupNative(stored) {
|
|
28725
30624
|
if (this.options.webrtcGroupLinks) return false;
|
|
28726
|
-
return typeof RTCPeerConnection === "undefined" || !!stored.peerTransports && !stored.peerTransports.includes("webrtc/1");
|
|
30625
|
+
return typeof RTCPeerConnection === "undefined" || this.edgeRtcOff.has(stored.id) || !!stored.peerTransports && !stored.peerTransports.includes("webrtc/1");
|
|
30626
|
+
}
|
|
30627
|
+
/**
|
|
30628
|
+
* An edge's WebRTC attempt exchanged both descriptions and connected nothing, or found no candidate at all
|
|
30629
|
+
* (`DirectEvidence`): a network that lets no direct connection through (a VPN, a firewall, on either side). WebRTC is
|
|
30630
|
+
* all an edge between two apps that have it runs, and there is no TURN server unless someone set one, so the edge
|
|
30631
|
+
* never went live, and the member stayed unreachable for as long as that network lasted. The edge starts again as on an
|
|
30632
|
+
* app with no WebRTC (WISP 9xx § Transports): it runs its native endpoints and says so in its packet (`_tr`, with no
|
|
30633
|
+
* `webrtc/1`), the member's app starts its own for it as it does for a Linux Desktop, and the two meet over Iroh
|
|
30634
|
+
* through its relay. For this run of the app only.
|
|
30635
|
+
*/
|
|
30636
|
+
async edgeWithoutRtc(linkId) {
|
|
30637
|
+
const live = this.links.get(linkId), link = live?.link;
|
|
30638
|
+
if (!live || !link || !live.stored.group || live.stored.groupEntry || this.shuttingDown || this.options.webrtcGroupLinks) return;
|
|
30639
|
+
if (this.edgeRtcOff.has(linkId) || this.keepsGroupNative(live.stored) || !Object.keys(this.nativeFactories).length) return;
|
|
30640
|
+
this.edgeRtcOff.add(linkId);
|
|
30641
|
+
traceLink(live.myPubKeyZ32, "edge-rtc-off", {});
|
|
30642
|
+
live.link = null;
|
|
30643
|
+
await link.stop(false).catch(() => {});
|
|
30644
|
+
if (this.links.get(linkId) !== live || live.link || this.shuttingDown || this.settings.online === false) return;
|
|
30645
|
+
this.startEdge(linkId);
|
|
28727
30646
|
}
|
|
28728
30647
|
startLink(linkId, messages) {
|
|
28729
30648
|
const live = this.links.get(linkId);
|
|
@@ -28731,7 +30650,7 @@ var GhostlyNode = class GhostlyNode {
|
|
|
28731
30650
|
const { stored } = live;
|
|
28732
30651
|
if (stored.group) return this.startEdge(linkId);
|
|
28733
30652
|
if (stored.profile) live.pairing = { status: "connecting" };
|
|
28734
|
-
const lastSeenTimestamp = messages.reduce((max, m) => m.sender === "peer" && m.via === "pkarr" ? Math.max(max, m.timestamp) : max, 0);
|
|
30653
|
+
const lastSeenTimestamp = messages.reduce((max, m) => m.sender === "peer" && m.via === "pkarr" ? Math.max(max, m.sentAt ?? m.timestamp) : max, 0);
|
|
28735
30654
|
const credentials = stored.profile && stored.participationSeed ? {
|
|
28736
30655
|
seedB64: stored.participationSeed,
|
|
28737
30656
|
peerKey: stored.pairedPeerKey,
|
|
@@ -28964,6 +30883,11 @@ var GhostlyNode = class GhostlyNode {
|
|
|
28964
30883
|
live.peerAck = ack;
|
|
28965
30884
|
this.emitState();
|
|
28966
30885
|
},
|
|
30886
|
+
onDirectEvidence: (evidence) => this.directPath.note(stored.peerPubKeyZ32, evidence),
|
|
30887
|
+
onPeerClock: (packetAt, readBefore, readAt) => {
|
|
30888
|
+
const peer = live.stored.pairedPeerKey;
|
|
30889
|
+
if (peer && !live.stored.group) this.clock.peer(peer, packetAt, readBefore, readAt);
|
|
30890
|
+
},
|
|
28967
30891
|
onDataLinkState: (state) => {
|
|
28968
30892
|
const was = live.dataLink;
|
|
28969
30893
|
live.dataLink = state;
|
|
@@ -29023,6 +30947,7 @@ var GhostlyNode = class GhostlyNode {
|
|
|
29023
30947
|
};
|
|
29024
30948
|
db.patchLink(linkId, { inviteCode: void 0 });
|
|
29025
30949
|
}
|
|
30950
|
+
if (message.via !== "datalink") this.chatInUse(live);
|
|
29026
30951
|
return this.storeMessage({
|
|
29027
30952
|
linkId,
|
|
29028
30953
|
id: `peer_${message.id ?? message.timestamp}`,
|
|
@@ -29100,7 +31025,8 @@ var GhostlyNode = class GhostlyNode {
|
|
|
29100
31025
|
};
|
|
29101
31026
|
},
|
|
29102
31027
|
changed: (record) => this.peerCapsChanged(linkId, record),
|
|
29103
|
-
published: () => live.link?.announceCapsRevision()
|
|
31028
|
+
published: () => live.link?.announceCapsRevision(),
|
|
31029
|
+
refused: (error) => traceLink(live.myPubKeyZ32, "caps-refused", { reason: error.reason })
|
|
29104
31030
|
});
|
|
29105
31031
|
if (stored.pairedPeerKey && nativeUp) {
|
|
29106
31032
|
const late = setTimeout(() => caps.start(), STARTUP_QUIET_MS);
|
|
@@ -29191,7 +31117,7 @@ var GhostlyNode = class GhostlyNode {
|
|
|
29191
31117
|
get nativeFactories() {
|
|
29192
31118
|
const relay = this.relaysHyperdht ? this.hyperdhtRelay : "";
|
|
29193
31119
|
return {
|
|
29194
|
-
...relay ? { "hyperdht/1": async (seedB64) => (await import("./hyperdhtRelay-
|
|
31120
|
+
...relay ? { "hyperdht/1": async (seedB64) => (await import("./hyperdhtRelay-VM9STKLd.js")).createRelayedHyperEndpoint(seedB64, relay) } : {},
|
|
29195
31121
|
...this.options.nativeTransports,
|
|
29196
31122
|
...this.options.irohWeb ? { "iroh/1": (seedB64) => createIrohWebEndpoint(seedB64, { relays: this.irohRelays }) } : {}
|
|
29197
31123
|
};
|
|
@@ -29254,70 +31180,110 @@ var GhostlyNode = class GhostlyNode {
|
|
|
29254
31180
|
}
|
|
29255
31181
|
this.emitState();
|
|
29256
31182
|
}
|
|
29257
|
-
|
|
31183
|
+
/**
|
|
31184
|
+
* A text going or coming in a 1:1 chat with no live session: the chat is in use, and takes the native listeners it
|
|
31185
|
+
* lacks as the chat on screen would (a bot answering its ninth contact has no chat on screen).
|
|
31186
|
+
*/
|
|
31187
|
+
chatInUse(live) {
|
|
31188
|
+
const link = live.link;
|
|
31189
|
+
if (live.stored.group || !link || link.isDataLinkOpen || !this.keepsNativeEndpoints(live.stored.id, live.stored)) return;
|
|
31190
|
+
if (Object.keys(this.nativeFactories).every((t) => link.availableTransports.includes(t))) return;
|
|
31191
|
+
this.ensureNativeEndpoints(live.stored.id, true);
|
|
31192
|
+
}
|
|
31193
|
+
/**
|
|
31194
|
+
* Whether the chat goes live only over a native transport: this app has no WebRTC, or its contact has none (its record
|
|
31195
|
+
* lists no `webrtc/1`). Not known yet (no record read) counts as only natively, as a chat before its first record must.
|
|
31196
|
+
*/
|
|
31197
|
+
reachedOnlyNatively(live) {
|
|
31198
|
+
const theirs = live.stored.capsState?.peer?.transports ?? live.stored.peerTransports;
|
|
31199
|
+
return typeof RTCPeerConnection === "undefined" || !theirs || !theirs.includes("webrtc/1");
|
|
31200
|
+
}
|
|
31201
|
+
/** `inUse`: a text is going or coming in the chat now, which counts as the chat on screen does for taking a listener. */
|
|
31202
|
+
ensureNativeEndpoints(linkId, inUse = false) {
|
|
29258
31203
|
const expected = this.links.get(linkId)?.link;
|
|
29259
|
-
const
|
|
31204
|
+
const starts = Object.entries(this.nativeFactories).map(([transport, factory]) => {
|
|
31205
|
+
const key = transport;
|
|
31206
|
+
const operation = (this.nativeQueues.get(key) ?? Promise.resolve()).then(() => factory ? this.startNativeEndpoint(linkId, expected, key, factory, inUse) : false);
|
|
31207
|
+
this.nativeQueues.set(key, operation.then(() => {}, () => {}));
|
|
31208
|
+
return operation;
|
|
31209
|
+
});
|
|
31210
|
+
return Promise.all(starts).then((full) => {
|
|
29260
31211
|
const live = this.links.get(linkId), link = live?.link;
|
|
29261
|
-
if (this.shuttingDown || !live
|
|
29262
|
-
|
|
29263
|
-
|
|
29264
|
-
|
|
29265
|
-
|
|
29266
|
-
|
|
29267
|
-
|
|
29268
|
-
|
|
29269
|
-
|
|
29270
|
-
|
|
29271
|
-
|
|
29272
|
-
|
|
29273
|
-
|
|
29274
|
-
|
|
29275
|
-
|
|
29276
|
-
|
|
29277
|
-
|
|
29278
|
-
|
|
29279
|
-
|
|
29280
|
-
|
|
29281
|
-
|
|
29282
|
-
|
|
29283
|
-
|
|
29284
|
-
|
|
29285
|
-
|
|
29286
|
-
|
|
29287
|
-
|
|
29288
|
-
|
|
29289
|
-
|
|
29290
|
-
|
|
29291
|
-
|
|
29292
|
-
|
|
29293
|
-
...live.
|
|
29294
|
-
[key]:
|
|
29295
|
-
};
|
|
29296
|
-
await db.patchLink(linkId, { transportSeeds });
|
|
29297
|
-
live.stored = {
|
|
29298
|
-
...live.stored,
|
|
29299
|
-
transportSeeds
|
|
31212
|
+
if (this.shuttingDown || !live || !link || link !== expected) return;
|
|
31213
|
+
if (live.stored.group && this.keepsGroupNative(live.stored)) {
|
|
31214
|
+
if (full.some(Boolean) && !link.availableTransports.some((t) => t !== "webrtc/1")) this.groupNativeWaiting.add(linkId);
|
|
31215
|
+
else this.groupNativeWaiting.delete(linkId);
|
|
31216
|
+
}
|
|
31217
|
+
this.emitState();
|
|
31218
|
+
});
|
|
31219
|
+
}
|
|
31220
|
+
/** One transport's listener for one chat, in that transport's queue. True when a group's link found no slot free. */
|
|
31221
|
+
async startNativeEndpoint(linkId, expected, key, factory, inUse) {
|
|
31222
|
+
const live = this.links.get(linkId), link = live?.link;
|
|
31223
|
+
if (this.shuttingDown || !live?.stored.profile || live.stored.deliveryMode === "dht" || !link || link !== expected) return false;
|
|
31224
|
+
const group = !!live.stored.group;
|
|
31225
|
+
if (group && !this.keepsGroupNative(live.stored)) return false;
|
|
31226
|
+
if (link.availableTransports.includes(key)) return false;
|
|
31227
|
+
live.transportErrors ??= {};
|
|
31228
|
+
try {
|
|
31229
|
+
const owners = [...this.links.values()].filter((other) => other.link?.availableTransports.includes(key));
|
|
31230
|
+
if (group && (owners.length >= NATIVE_SLOTS || owners.filter((other) => other.stored.group).length >= 4)) return true;
|
|
31231
|
+
if (owners.length >= NATIVE_SLOTS) {
|
|
31232
|
+
const now = Date.now();
|
|
31233
|
+
const taking = !group && (this.activeLinkId === linkId || inUse);
|
|
31234
|
+
const victim = owners.filter((other) => other.stored.group && other.link?.canReleaseEndpoint(key)).sort((a, b) => (a.link?.isDataLinkOpen ? 1 : 0) - (b.link?.isDataLinkOpen ? 1 : 0))[0] ?? (taking ? owners.filter((other) => other !== live && other.stored.id !== this.activeLinkId && other.link?.canReleaseEndpoint(key) && now - (other.nativeTakenAt?.[key] ?? 0) >= 12e4).sort((a, b) => a.lastMessageAt - b.lastMessageAt)[0] : void 0);
|
|
31235
|
+
const idle = victim || !taking || !this.reachedOnlyNatively(live) ? void 0 : owners.filter((other) => other !== live && !other.stored.group && other.stored.id !== this.activeLinkId && !this.fileDesk.moving(other.stored.id) && other.link?.canYieldEndpoint(key, 12e4)).sort((a, b) => a.link.lastActivityAt - b.link.lastActivityAt)[0];
|
|
31236
|
+
const idleFor = idle ? now - idle.link.lastActivityAt : 0;
|
|
31237
|
+
if (idle && await idle.link.yieldEndpoint(key, 12e4)) {
|
|
31238
|
+
traceLink(live.myPubKeyZ32, "native-take", {
|
|
31239
|
+
transport: key,
|
|
31240
|
+
idle: idleFor,
|
|
31241
|
+
active: this.activeLinkId === linkId
|
|
31242
|
+
});
|
|
31243
|
+
live.nativeTakenAt = {
|
|
31244
|
+
...live.nativeTakenAt,
|
|
31245
|
+
[key]: Date.now()
|
|
29300
31246
|
};
|
|
29301
|
-
|
|
29302
|
-
|
|
29303
|
-
|
|
29304
|
-
|
|
29305
|
-
|
|
29306
|
-
|
|
29307
|
-
|
|
29308
|
-
this.
|
|
29309
|
-
|
|
29310
|
-
|
|
31247
|
+
idle.transportErrors ??= {};
|
|
31248
|
+
idle.transportErrors[key] = "Listener given to a chat in use: this one was quiet. Open this chat to take one back; your messages and transport identity are saved.";
|
|
31249
|
+
} else if (!victim) {
|
|
31250
|
+
traceLink(live.myPubKeyZ32, "native-no-slot", {
|
|
31251
|
+
transport: key,
|
|
31252
|
+
active: this.activeLinkId === linkId
|
|
31253
|
+
});
|
|
31254
|
+
if (this.activeLinkId === linkId && !group) this.retryActiveSlot(linkId);
|
|
31255
|
+
throw new Error("All eight native connection slots are in use. This chat takes one once a chat live over one has been quiet for 2 minutes. Disconnect a native connection in another chat to free one now.");
|
|
31256
|
+
} else {
|
|
31257
|
+
await victim.link.releaseEndpoint(key);
|
|
31258
|
+
victim.transportErrors ??= {};
|
|
31259
|
+
victim.transportErrors[key] = "Listener released for another chat. Open this chat to restore it; your messages and transport identity are saved.";
|
|
31260
|
+
if (victim.stored.group) this.groupNativeWaiting.add(victim.stored.id);
|
|
29311
31261
|
}
|
|
29312
31262
|
}
|
|
29313
|
-
if (
|
|
29314
|
-
|
|
29315
|
-
|
|
31263
|
+
if (this.shuttingDown || live.link !== link) return false;
|
|
31264
|
+
const seed = live.stored.transportSeeds?.[key] ?? createIdentity().seedB64;
|
|
31265
|
+
const transportSeeds = {
|
|
31266
|
+
...live.stored.transportSeeds,
|
|
31267
|
+
[key]: seed
|
|
31268
|
+
};
|
|
31269
|
+
live.stored = {
|
|
31270
|
+
...live.stored,
|
|
31271
|
+
transportSeeds
|
|
31272
|
+
};
|
|
31273
|
+
await db.patchLink(linkId, { transportSeeds });
|
|
31274
|
+
const endpoint = await factory(seed);
|
|
31275
|
+
if (this.shuttingDown || live.stored.deliveryMode === "dht" || live.link !== link || !this.links.has(linkId)) {
|
|
31276
|
+
await endpoint.close();
|
|
31277
|
+
return false;
|
|
29316
31278
|
}
|
|
31279
|
+
link.registerEndpoint(endpoint);
|
|
31280
|
+
delete live.transportErrors[key];
|
|
31281
|
+
this.capsChanged(linkId);
|
|
29317
31282
|
this.emitState();
|
|
29318
|
-
})
|
|
29319
|
-
|
|
29320
|
-
|
|
31283
|
+
} catch (error) {
|
|
31284
|
+
live.transportErrors[key] = error instanceof Error ? error.message : "Native adapter could not start. Reopen this chat to retry.";
|
|
31285
|
+
}
|
|
31286
|
+
return false;
|
|
29321
31287
|
}
|
|
29322
31288
|
/**
|
|
29323
31289
|
* The order saved chats start in, which is the order they take native listeners in (`NATIVE_SLOTS` per transport):
|
|
@@ -29446,15 +31412,54 @@ var GhostlyNode = class GhostlyNode {
|
|
|
29446
31412
|
deleted: unique.filter((_, i) => !rows[i])
|
|
29447
31413
|
});
|
|
29448
31414
|
}
|
|
31415
|
+
/**
|
|
31416
|
+
* At the start, before anything reads a history: received rows an earlier version stored under a time that has not
|
|
31417
|
+
* come yet take a place before now (`settleAhead`). One row is read when nothing is ahead, which is every start but
|
|
31418
|
+
* the first after the update.
|
|
31419
|
+
*/
|
|
31420
|
+
async settleHistory(linkId) {
|
|
31421
|
+
const now = Date.now();
|
|
31422
|
+
const newest = (await db.getMessagePage(linkId, { limit: 1 })).messages[0];
|
|
31423
|
+
if (!newest || newest.timestamp <= now) return;
|
|
31424
|
+
for (const row of settleAhead(await db.getMessages(linkId), now)) await db.putMessage(row);
|
|
31425
|
+
}
|
|
31426
|
+
/**
|
|
31427
|
+
* A new row with its place in its history. A received row takes it here and now, past the last place given in its
|
|
31428
|
+
* chat: the time its sender says is kept beside it (`sentAt`) and shown, and orders nothing (WISP 400, requirement
|
|
31429
|
+
* 10). A sender's clock ahead or behind then moves no message past another, and what I send next goes below what I
|
|
31430
|
+
* just received. A row of mine keeps its own time; one placed already (it has `sentAt`) is left as it is. A line
|
|
31431
|
+
* of a group's history (a membership change, a rename) is not a message: it stays at its commit's time, but never
|
|
31432
|
+
* ahead of this clock nor above a row placed here since the app started. The admin's clock picks that time: two
|
|
31433
|
+
* minutes ahead, "X joined" sat under everything said in the next two minutes; behind, it went above what the
|
|
31434
|
+
* members had just read. Read late with nothing newer here (a member back after a while), it keeps its moment.
|
|
31435
|
+
*/
|
|
31436
|
+
placed(message) {
|
|
31437
|
+
const now = Date.now(), last = this.placedAt.get(message.linkId) ?? 0;
|
|
31438
|
+
if (message.event) {
|
|
31439
|
+
if (message.sender !== "peer") return message;
|
|
31440
|
+
const timestamp = Math.max(Math.min(receivedTimestamp(message.timestamp, now), now), last + 1);
|
|
31441
|
+
this.placedAt.set(message.linkId, timestamp);
|
|
31442
|
+
return {
|
|
31443
|
+
...message,
|
|
31444
|
+
timestamp
|
|
31445
|
+
};
|
|
31446
|
+
}
|
|
31447
|
+
if (message.sender === "peer" && message.sentAt === void 0) {
|
|
31448
|
+
const sentAt = claimedTime(message.timestamp);
|
|
31449
|
+
message = {
|
|
31450
|
+
...message,
|
|
31451
|
+
timestamp: arrivalKey(now, last),
|
|
31452
|
+
...sentAt !== void 0 && { sentAt }
|
|
31453
|
+
};
|
|
31454
|
+
this.placedAt.set(message.linkId, message.timestamp);
|
|
31455
|
+
} else this.placedAt.set(message.linkId, Math.max(last, Math.min(message.timestamp, now)));
|
|
31456
|
+
return message;
|
|
31457
|
+
}
|
|
29449
31458
|
async storeMessage(message) {
|
|
29450
31459
|
await this.storeNewMessage(message);
|
|
29451
31460
|
}
|
|
29452
31461
|
/** Stores a message; false when it was there already (or deleted here): nothing new came. */
|
|
29453
31462
|
async storeNewMessage(message) {
|
|
29454
|
-
if (message.sender === "peer") message = {
|
|
29455
|
-
...message,
|
|
29456
|
-
timestamp: receivedTimestamp(message.timestamp)
|
|
29457
|
-
};
|
|
29458
31463
|
const edge = this.links.get(message.linkId)?.stored;
|
|
29459
31464
|
if (edge?.group && edge.groupPeer && !edge.groupEntry) message = {
|
|
29460
31465
|
...message,
|
|
@@ -29469,6 +31474,7 @@ var GhostlyNode = class GhostlyNode {
|
|
|
29469
31474
|
id: `${message.linkId}:${message.id}`,
|
|
29470
31475
|
...message.sender === "peer" ? { member: pay.member } : {}
|
|
29471
31476
|
};
|
|
31477
|
+
message = this.placed(message);
|
|
29472
31478
|
const live = this.links.get(message.linkId);
|
|
29473
31479
|
if (live?.stored.deletedIds?.includes(message.id)) return false;
|
|
29474
31480
|
message = await this.resolveReply(message);
|
|
@@ -29497,8 +31503,9 @@ var GhostlyNode = class GhostlyNode {
|
|
|
29497
31503
|
};
|
|
29498
31504
|
}
|
|
29499
31505
|
if (!await db.addMessage(message)) return false;
|
|
31506
|
+
this.clockSample(message);
|
|
29500
31507
|
const newest = this.newestAt.get(message.linkId);
|
|
29501
|
-
if (!message.event) this.newestAt.set(message.linkId, Math.max(newest ?? 0, message.
|
|
31508
|
+
if (!message.event) this.newestAt.set(message.linkId, Math.max(newest ?? 0, writtenAt(message, this.clockLeads.get(message.linkId))));
|
|
29502
31509
|
if (message.sender === "peer") this.messageFeedback("message", message, newest);
|
|
29503
31510
|
if (live) live.lastMessageAt = Math.max(live.lastMessageAt, message.timestamp);
|
|
29504
31511
|
const early = message.sender === "peer" && message.id.startsWith("peer_") ? this.editBuffer.take(message.linkId, message.id.slice(5)) : void 0;
|
|
@@ -29583,7 +31590,7 @@ var GhostlyNode = class GhostlyNode {
|
|
|
29583
31590
|
status: live.status,
|
|
29584
31591
|
dataLink: live.dataLink,
|
|
29585
31592
|
peerOnline: presence.online,
|
|
29586
|
-
peerLastSeenAt: presence
|
|
31593
|
+
peerLastSeenAt: presenceSeenAt(presence),
|
|
29587
31594
|
peerServices: presence.services,
|
|
29588
31595
|
lastMessageAt: live.lastMessageAt,
|
|
29589
31596
|
peerAck: live.peerAck,
|
|
@@ -29672,6 +31679,7 @@ var EngineServer = class {
|
|
|
29672
31679
|
}
|
|
29673
31680
|
}, options);
|
|
29674
31681
|
this.ready = this.node.start();
|
|
31682
|
+
this.ready.catch(() => {});
|
|
29675
31683
|
}
|
|
29676
31684
|
attach(client) {
|
|
29677
31685
|
this.clients.add(client);
|
|
@@ -29697,6 +31705,11 @@ var EngineServer = class {
|
|
|
29697
31705
|
});
|
|
29698
31706
|
this.histories.get(client)?.add(link.id);
|
|
29699
31707
|
});
|
|
31708
|
+
}, (error) => {
|
|
31709
|
+
this.post(client, {
|
|
31710
|
+
kind: "start-failed",
|
|
31711
|
+
failure: profileOpenFailure(error)
|
|
31712
|
+
});
|
|
29700
31713
|
}).catch(() => {
|
|
29701
31714
|
this.clients.delete(client);
|
|
29702
31715
|
});
|
|
@@ -29804,4 +31817,4 @@ function signalKind(signal) {
|
|
|
29804
31817
|
//#endregion
|
|
29805
31818
|
export { EngineServer, hyperdhtRelayProblem as n, server_exports as t };
|
|
29806
31819
|
|
|
29807
|
-
//# sourceMappingURL=server-
|
|
31820
|
+
//# sourceMappingURL=server-BnNXYzR9.js.map
|