@ghostlytools/cli 1.0.1 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,4 @@
1
- import { $ as boundedIdentityFetch, $n as LIVENESS_PING_MS, $r as IDENTITY_REVOCATION_LABEL, $t as GroupWakeLimiter, A as TEST_MINT, Ai as ReactionWindow, An as assertTokenUnits, Ar as nextPinNumber, At as transact, B as registeredLightningProviders, Ba as toZ32, Bi as replySnippet, Bn as isPubkyKey, Br as vapidKeysMatch, Bt as communityHasFrame, C as networkLabel, Ca as normalizeRelayUrl, Ci as dhtEditId, Cn as ONCHAIN_PROVIDER, Cr as transferEnded, Ct as STORES, D as WALLET_TYPES, Da as PacketTooLargeError, Di as COMMUNITY_REACTION_FRAME, Dn as TEST_USDT_FAUCET_AMOUNT, Dt as fileStore, Ea as withRequestOptions, Ei as readForwarded, En as SEPOLIA_TEST_USDT_FAUCET, Er as typingActivity, Et as deleteDatabase, F as paysItsOwnInvoices, Fa as sanitizeAvatar, Fi as readReaction, Fn as SPARK_NETWORKS, Fr as WakeLimiter, Ft as openGroupTyping, G as SourceConfigError, Gi as statusCardText, Gn as cashuRequestPayload, Gr as HoldKeys, Gt as decodeGroupEntryLink, H as reserveAdapterIds, Ha as utf8Encode, Hi as STATUS_CARD_LIMITS, Hn as engineText, Hr as EDIT_CAPABILITY, Ht as decodeCommunityLink, I as BACKUP_MEDIA_TYPE, Ia as bytesEqual, Ii as validReactionNumber, In as SPARK_PROVIDER, Ir as newWakeToken, It as sealGroupTyping, J as networkMode, Jn as isFederationId, Jr as readManifest, Jt as knockIdentity, K as SourceUnreachableError, Ki as receivedTimestamp, Kn as fedimintRequestPayload, Kr as HoldRefusedError, Kt as encodeGroupEntryLink, L as MAX_BACKUP_BYTES, La as fromBase64Url, Li as wireReaction, Ln as sparkAddressKind, Lr as relayRequest, Lt as COMMUNITY_LIMITS, M as isTestMint, Ma as identityFromSeed, Mi as queueReaction, Mn as formatPaymentAmount, Mr as pinNumberHolds, N as isWorthlessMint, Na as identityFromSeedB64, Ni as reactionEmoji, Nn as validatePaymentTarget, Nr as readPin, Nt as GroupTypingBoard, O as BITCOIN_INVOICE_ON_TESTNET, Oi as GROUP_REACTION_FRAME, On as USDT_PUBLIC_RPC, Or as PIN_LIMITS, Ot as openDb, P as mintNetwork, Pa as publicKeyFromZ32, Pi as reactionIsNewer, Pn as walletNetworkOf, Pr as WAKE_CALL_INTERVAL_MS, Pt as groupTypingEpoch, Q as setOwnDidSource, Qi as edgeParams, Qn as INVITE_TAKEN, Qr as dialDescriptors, R as identityProviders, Ra as randomBytes, Ri as pairedReplyAuthor, Rn as sparkInvoiceDetails, Rr as wakeRequest, Rt as CommunitySession, S as WrongNetworkError, Sa as currentRelays, Si as RateWindow, Sn as EVM_TEST_CHAINS, Sr as FILE_LIMITS, T as cashuMint, Ta as isDiscoveryBudgetError, Ti as forwardedAgain, Tn as SEPOLIA_TEST_USDT, Tr as TypingSender, Tt as databaseName, U as NothingSpentError, Ua as __exportAll, Ui as checkStatusCard, Un as decodeBolt11, Ur as HOLD_CAPABILITY, Ut as encodeCommunityLink, V as registeredOnchainProviders, Va as utf8Decode, Vi as BUTTON_ID, Vn as engineError, Vt as communityMessageId, W as PROVIDER_PLATFORMS, Wi as sameButtonText, Wn as ENDPOINT, Wr as HOLD_LIMITS, X as assertPublicServer, Xi as rosterHas, Xn as parseFedimintRequestPayload, Xr as DHT_TEXT_CAPABILITY, Xt as mergeKnocks, Y as redact, Yi as MEMBER_KEY, Yn as parseCashuRequestPayload, Yr as CapsExchange, Yt as knockRecords, Z as resolveAtprotoDid, Zi as verifyCommitSignature, Zn as GhostLink, Zr as capsDescriptors, Zt as readKnocks, _ as newDeviceKey, _a as decodeInviteCode, _i as parseVideoMeta, _n as didDhtFromPublicKey, _r as readBeacon, _t as fileBytes, a as createFailure, aa as webLocalFetch, ai as receivedIdentityStatus, an as GroupSession, ar as COMMUNITY_TOPOLOGY, at as replyRef, b as ModeChanged, ba as DEFAULT_RELAYS, bi as EDIT_BUFFER_MS, bn as signDidDhtPacket, br as shouldBeHub, bt as removeFileBytes, c as paymentNetwork, ca as encodeControl, ci as PeerProofs, cr as doorHubs, ct as paymentWireId, d as networkReason, da as emptyLinkRecords, di as TRANSPORTS, dn as parseCommunityEdit, dr as lobbyRecords, dt as removeStored, ea as RELAY_POLL_INTERVALS, ei as IdentityExchange, en as groupWakeFrame, er as pairedMessageFrame, et as verifyIdentity, f as rpcHost, fa as HTTP_SERVICE_PROTO, fi as automaticTransport, fn as mentionsMember, fr as mergeBeacon, ft as storedSize, g as intentRepository, ga as createChatInvite, gi as parseVoiceMeta, gn as didDhtDocument, gr as rankHubs, h as ProviderSources, ha as OwnInviteError, hi as fileMessageText, hn as normalizeAtprotoHandle, hr as pickHubs, ht as blobDigest, i as assertConfirmedReal, ia as parseLocalTarget, ii as newIdentityBinding, in as GROUP_READ_NOTE, ir as pickMeshHubs, it as receivedPairedReply, j as fakesLightning, ja as createIdentity, ji as nextReactionNumber, jn as assertWholeSats, jr as pinIsNewer, jt as wrap, k as DEFAULT_MINTS, ki as REACTION_LIMITS, kn as WALLET_NETWORKS, kr as mayPin, kt as store, l as paymentNetworksOf, la as RTC_CONFIG, li as emptyProofLedger, ln as GROUP_EDIT_TEXT_BYTES, lr as freshHubs, lt as FileAppender, m as LightningCards, ma as serviceIdFromName, mi as safeBlobType, mn as isAtprotoDid, mr as newerHead, n as loadFedimintSdk, na as GhostlyHttpError, ni as identityRevocationValue, nn as parseGroupWakeFrame, nr as mayBeHub, nt as groupReply, o as createTiming, oa as LIMITS, oi as revokesIdentity, on as groupMessageId, or as beaconKeys, ot as replyTo, p as CASHU_CARD, pa as LEGACY_SERVICES, pi as parseImageMeta, pn as groupName, pr as mergeLobby, pt as streamStored, q as isNothingSpentError, qi as GROUP_MEMBER_CAP, qn as findEndpoint, qr as newMailbox, qt as entryParams, r as WALLET_NAMES, ra as formatLocalTarget, ri as identityStatement, rr as meshHubs, rt as pairedWireReply, s as crossNetwork, sa as decodeControl, si as lastSharedWithMe, sr as beaconRecords, st as paymentAlias, t as fedimintDatabase, ta as traceLink, ti as emptyIdentityLedger, tn as groupWakes, tr as MESH_HUBS, tt as chosenResolver, u as walletInstances, ua as parseCallSignal, ui as validExternalKey, un as carryMentions, ur as lobbyKeys, ut as readStored, v as sealSeed, va as encodeInviteCode, vi as DHT_MESSAGE_TTL, vn as encodeDidDhtDeactivated, vr as readBeaconHead, vt as fileBytesOf, w as CASHU_MINT_SOURCE, wa as budgetRetryMs, wi as parseLinkPreview, wn as PaymentPreflightError, wr as transferStalled, wt as clearProfileStores, x as ModeGate, xa as RelayTransport, xi as EDIT_RATE_WINDOW_MS, xn as ETHEREUM_USDT, xr as ChatFiles, y as unsealSeed, ya as inviteOwnership, yi as peekDhtMailbox, yn as encodeDidDhtPacket, yr as readLobby, z as onAdaptersChanged, za as toBase64Url, zi as readReply, zn as isBitcoinAddress, zr as checkPushEndpoint, zt as GROUP_READ_NOTE_COMMUNITY } from "../ghostly.mjs";
1
+ import { $ as boundedIdentityFetch, $i as GROUP_MEMBER_CAP, $n as LIVENESS_PING_MS, $r as IDENTITY_REVOCATION_LABEL, $t as GroupWakeLimiter, A as TEST_MINT, Aa as normalizeRelayUrl, Ai as GROUP_REACTION_FRAME, An as assertTokenUnits, Ar as nextPinNumber, At as transact, B as registeredLightningProviders, Ba as identityFromSeedB64, Bi as pairedReplyAuthor, Bn as isPubkyKey, Br as vapidKeysMatch, Bt as communityHasFrame, C as networkLabel, Ca as createChatInvite, Ci as EDIT_RATE_WINDOW_MS, Cn as ONCHAIN_PROVIDER, Cr as transferEnded, Ct as STORES, D as WALLET_TYPES, Da as DEFAULT_RELAYS, Di as forwardedAgain, Dn as TEST_USDT_FAUCET_AMOUNT, Dt as fileStore, Ea as inviteOwnership, Ei as parseLinkPreview, En as SEPOLIA_TEST_USDT_FAUCET, Er as typingActivity, Et as deleteDatabase, F as paysItsOwnInvoices, Fi as reactionEmoji, Fn as SPARK_NETWORKS, Fr as WakeLimiter, Ft as openGroupTyping, G as SourceConfigError, Ga as randomBytes, Gi as checkStatusCard, Gn as cashuRequestPayload, Gr as HoldKeys, Gt as decodeGroupEntryLink, H as reserveAdapterIds, Ha as sanitizeAvatar, Hi as replySnippet, Hn as engineText, Hr as EDIT_CAPABILITY, Ht as decodeCommunityLink, I as BACKUP_MEDIA_TYPE, Ii as reactionIsNewer, In as SPARK_PROVIDER, Ir as newWakeToken, It as sealGroupTyping, J as networkMode, Ja as utf8Decode, Ji as arrivalKey, Jn as isFederationId, Jr as readManifest, Jt as knockIdentity, K as SourceUnreachableError, Ka as toBase64Url, Ki as sameButtonText, Kn as fedimintRequestPayload, Kr as HoldRefusedError, Kt as encodeGroupEntryLink, L as MAX_BACKUP_BYTES, Li as readReaction, Ln as sparkAddressKind, Lr as relayRequest, Lt as COMMUNITY_LIMITS, M as isTestMint, Ma as isDiscoveryBudgetError, Mi as ReactionWindow, Mn as formatPaymentAmount, Mr as pinNumberHolds, N as isWorthlessMint, Na as withRequestOptions, Ni as nextReactionNumber, Nn as validatePaymentTarget, Nr as readPin, Nt as GroupTypingBoard, O as BITCOIN_INVOICE_ON_TESTNET, Oa as RelayTransport, Oi as readForwarded, On as USDT_PUBLIC_RPC, Or as PIN_LIMITS, Ot as openDb, P as mintNetwork, Pa as PacketTooLargeError, Pi as queueReaction, Pn as walletNetworkOf, Pr as WAKE_CALL_INTERVAL_MS, Pt as groupTypingEpoch, Q as setOwnDidSource, Qi as shownTime, Qn as INVITE_TAKEN, Qr as dialDescriptors, R as identityProviders, Ra as createIdentity, Ri as validReactionNumber, Rn as sparkInvoiceDetails, Rr as wakeRequest, Rt as CommunitySession, S as WrongNetworkError, Sa as OwnInviteError, Si as EDIT_BUFFER_MS, Sn as EVM_TEST_CHAINS, Sr as FILE_LIMITS, T as cashuMint, Ta as encodeInviteCode, Ti as dhtEditId, Tn as SEPOLIA_TEST_USDT, Tr as TypingSender, Tt as databaseName, U as NothingSpentError, Ua as bytesEqual, Ui as BUTTON_ID, Un as decodeBolt11, Ur as HOLD_CAPABILITY, Ut as encodeCommunityLink, V as registeredOnchainProviders, Va as publicKeyFromZ32, Vi as readReply, Vn as engineError, Vt as communityMessageId, W as PROVIDER_PLATFORMS, Wa as fromBase64Url, Wi as STATUS_CARD_LIMITS, Wn as ENDPOINT, Wr as HOLD_LIMITS, X as assertPublicServer, Xa as __exportAll, Xi as heardTime, Xn as parseFedimintRequestPayload, Xr as DHT_TEXT_CAPABILITY, Xt as mergeKnocks, Y as redact, Ya as utf8Encode, Yi as claimedTime, Yn as parseCashuRequestPayload, Yr as CapsExchange, Yt as knockRecords, Z as resolveAtprotoDid, Zi as receivedTimestamp, Zn as GhostLink, Zr as capsDescriptors, Zt as readKnocks, _ as newDeviceKey, _a as parseCallSignal, _i as fileMessageText, _n as didDhtFromPublicKey, _r as readBeacon, _t as fileBytes, a as createFailure, aa as DirectPathWatch, ai as receivedIdentityStatus, an as GroupSession, ar as COMMUNITY_TOPOLOGY, at as replyRef, b as ModeChanged, ba as LEGACY_SERVICES, bi as DHT_MESSAGE_TTL, bn as signDidDhtPacket, br as shouldBeHub, bt as removeFileBytes, c as paymentNetwork, ca as traceLink, ci as PeerProofs, cr as doorHubs, ct as paymentWireId, d as networkReason, da as parseLocalTarget, di as TRANSPORTS, dn as parseCommunityEdit, dr as lobbyRecords, dt as removeStored, ei as IdentityExchange, en as groupWakeFrame, er as pairedMessageFrame, et as verifyIdentity, f as rpcHost, fa as webLocalFetch, fi as automaticTransport, fn as mentionsMember, fr as mergeBeacon, ft as storedSize, g as intentRepository, ga as RTC_CONFIG, gi as safeBlobType, gn as didDhtDocument, gr as rankHubs, h as ProviderSources, ha as encodeControl, hi as parseImageMeta, hn as normalizeAtprotoHandle, hr as pickHubs, ht as blobDigest, i as assertConfirmedReal, ia as edgeParams, ii as newIdentityBinding, in as GROUP_READ_NOTE, ir as pickMeshHubs, it as receivedPairedReply, j as fakesLightning, ja as budgetRetryMs, ji as REACTION_LIMITS, jn as assertWholeSats, jr as pinIsNewer, jt as wrap, k as DEFAULT_MINTS, ka as currentRelays, ki as COMMUNITY_REACTION_FRAME, kn as WALLET_NETWORKS, kr as mayPin, kt as store, l as paymentNetworksOf, la as GhostlyHttpError, li as emptyProofLedger, ln as GROUP_EDIT_TEXT_BYTES, lr as freshHubs, lt as FileAppender, m as LightningCards, ma as decodeControl, mi as irohRelayUrl, mn as isAtprotoDid, mr as newerHead, n as loadFedimintSdk, na as rosterHas, ni as identityRevocationValue, nn as parseGroupWakeFrame, nr as mayBeHub, nt as groupReply, o as createTiming, oi as revokesIdentity, on as groupMessageId, or as beaconKeys, ot as replyTo, p as CASHU_CARD, pa as LIMITS, pi as irohDescriptor, pn as groupName, pr as mergeLobby, pt as streamStored, q as isNothingSpentError, qa as toZ32, qi as statusCardText, qn as findEndpoint, qr as newMailbox, qt as entryParams, r as WALLET_NAMES, ra as verifyCommitSignature, ri as identityStatement, rr as meshHubs, rt as pairedWireReply, s as crossNetwork, sa as RELAY_POLL_INTERVALS, si as lastSharedWithMe, sr as beaconRecords, st as paymentAlias, t as fedimintDatabase, ta as MEMBER_KEY, ti as emptyIdentityLedger, tn as groupWakes, tr as MESH_HUBS, tt as chosenResolver, u as walletInstances, ua as formatLocalTarget, ui as validExternalKey, un as carryMentions, ur as lobbyKeys, ut as readStored, v as sealSeed, va as emptyLinkRecords, vi as parseVoiceMeta, vn as encodeDidDhtDeactivated, vr as readBeaconHead, vt as fileBytesOf, w as CASHU_MINT_SOURCE, wa as decodeInviteCode, wi as RateWindow, wn as PaymentPreflightError, wr as transferStalled, wt as clearProfileStores, x as ModeGate, xa as serviceIdFromName, xi as peekDhtMailbox, xn as ETHEREUM_USDT, xr as ChatFiles, y as unsealSeed, ya as HTTP_SERVICE_PROTO, yi as parseVideoMeta, yn as encodeDidDhtPacket, yr as readLobby, z as onAdaptersChanged, za as identityFromSeed, zi as wireReaction, zn as isBitcoinAddress, zr as checkPushEndpoint, zt as GROUP_READ_NOTE_COMMUNITY } from "../ghostly.mjs";
2
2
  import { base64, base64url, base64urlnopad, bech32, hex } from "@scure/base";
3
3
  import { hkdf } from "@noble/hashes/hkdf.js";
4
4
  import { sha256 } from "@noble/hashes/sha2.js";
@@ -11407,6 +11407,7 @@ function composeDetails(message, around) {
11407
11407
  linkId: message.linkId,
11408
11408
  sender: message.sender,
11409
11409
  timestamp: message.timestamp,
11410
+ ...message.sentAt !== void 0 && { sentAt: message.sentAt },
11410
11411
  via: message.via,
11411
11412
  delivery: message.delivery,
11412
11413
  deliveryError: message.deliveryError,
@@ -11579,6 +11580,11 @@ var FileDesk = class {
11579
11580
  peerRoom: files?.peerRoom ?? null
11580
11581
  };
11581
11582
  }
11583
+ /** A files/3 transfer of this chat's under way on its session: offered, moving, or being checked. */
11584
+ moving(linkId) {
11585
+ const files = this.chats.get(linkId)?.files;
11586
+ return !!files?.live && files.records().some((r) => r.state === "offered" || r.state === "active" || r.state === "verifying");
11587
+ }
11582
11588
  /**
11583
11589
  * Offers a file whose record and bytes are stored under its local id (`outgoingFileId`). Offered before (Retry, a
11584
11590
  * restart): an unfinished transfer is taken over and offered again, a failed one sent again; never a second one.
@@ -12234,7 +12240,7 @@ function takesPeerEdit(message, text) {
12234
12240
  function withEdit(message, edit) {
12235
12241
  const before = message.edit?.history ?? [];
12236
12242
  const history = edit.text === message.text || edit.card && edit.card.kind !== "buttons" ? before : trimHistory([...before, {
12237
- at: message.edit?.at ?? message.timestamp,
12243
+ at: message.edit?.at ?? shownTime(message),
12238
12244
  text: message.text
12239
12245
  }]);
12240
12246
  const next = {
@@ -13381,6 +13387,32 @@ var db = {
13381
13387
  }
13382
13388
  };
13383
13389
  //#endregion
13390
+ //#region ../browser/src/engine/arrival.ts
13391
+ /**
13392
+ * A history stored before a received row was placed where it arrived (WISP 400, requirement 10; until 2026-10): a
13393
+ * contact's row sits under the time the contact's clock said, up to five minutes ahead of this device's. Almost all of
13394
+ * it stays as it is: its stored time is its place, so nothing moves when the app updates. Only the rows whose stored
13395
+ * time has not come yet are settled, once, at the start: they are the last rows of their chat, and left there a
13396
+ * message written in the next minutes would sort above them. Each takes a place just before now, in the order they
13397
+ * were in and after everything before them, and keeps the time its sender said beside it (`sentAt`).
13398
+ *
13399
+ * Returns the rows to write, as they are now; none when nothing is ahead. Rows of mine are never touched: their time
13400
+ * is the one both sides know them by.
13401
+ */
13402
+ function settleAhead(history, now) {
13403
+ const ahead = history.filter((m) => m.sender === "peer" && m.sentAt === void 0 && m.timestamp > now);
13404
+ if (!ahead.length) return [];
13405
+ let last = history.reduce((max, m) => m.timestamp > now ? max : Math.max(max, m.timestamp), 0);
13406
+ return ahead.map((m, i) => {
13407
+ last = Math.max(last + 1, now - (ahead.length - i));
13408
+ return {
13409
+ ...m,
13410
+ timestamp: last,
13411
+ sentAt: m.timestamp
13412
+ };
13413
+ });
13414
+ }
13415
+ //#endregion
13384
13416
  //#region ../browser/src/engine/reactions.ts
13385
13417
  var isGroup$1 = (chat) => chat.startsWith("group:");
13386
13418
  /** The row a reaction names in a chat: the id both sides know it by (`replyRef`). */
@@ -13721,6 +13753,26 @@ var HUB_GRACE_MS = 18e4;
13721
13753
  var REFUSED_FOR_MS$1 = 6e5;
13722
13754
  var ENTRY_LINGER_MS$1 = 2e4;
13723
13755
  var RELAYED_KEPT = 4096;
13756
+ /**
13757
+ * Someone the chain took out while its app was closed comes back believing it is a member: it asks in a hub's lobby,
13758
+ * 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
13759
+ * (`farewell`): at most `FAREWELL_EDGES` at a time, each awaited `FAREWELL_WAIT_MS` and closed `FAREWELL_LINGER_MS`
13760
+ * after the commit went; the same key is not waited for again for a while (longer once it was told).
13761
+ */
13762
+ var FAREWELL_EDGES = 4;
13763
+ var FAREWELL_WAIT_MS = 12e4;
13764
+ var FAREWELL_LINGER_MS = 1e4;
13765
+ var FAREWELL_RETRY_MS = 6e4;
13766
+ var FAREWELL_TOLD_MS = 6e5;
13767
+ var FAREWELL_SAY_MS = 1e4;
13768
+ /**
13769
+ * A key asking from before the commit that took it out was there and was sent that commit: not waited for. The two
13770
+ * times are two devices' clocks (the asker's, the commit's signer's), so "before" allows this much between them:
13771
+ * a returning member whose clock runs behind the admin's is told all the same.
13772
+ */
13773
+ var FAREWELL_SKEW_MS = 3e5;
13774
+ /** A member that opens the group's link again knocks to check it is still in, this long at most (`joinByLink`). */
13775
+ var CHECK_MS = 18e4;
13724
13776
  var MESSAGE_LINK$1 = (groupId) => `group:${groupId}`;
13725
13777
  /**
13726
13778
  * A member key for a joiner whose entry session the member's side dials. Of a paired session's two
@@ -13752,6 +13804,8 @@ var Communities = class {
13752
13804
  /** Joiners: groups whose knock is out (published), and the knock record it is in. */
13753
13805
  knocked = /* @__PURE__ */ new Set();
13754
13806
  knockAt = /* @__PURE__ */ new Map();
13807
+ /** Joiners: how many others had a knock still being refreshed in the record I last read (they wait with me). */
13808
+ knockingWith = /* @__PURE__ */ new Map();
13755
13809
  /** My frames an edge took, and which of them carry an edit (`frame id → editKey`). */
13756
13810
  frames = new FramesTaken();
13757
13811
  carriers = /* @__PURE__ */ new Map();
@@ -13796,9 +13850,11 @@ var Communities = class {
13796
13850
  const history = await this.store.getMessages(MESSAGE_LINK$1(group.id)), said = history.filter((m) => !m.event && !m.groupPay);
13797
13851
  const last = said[said.length - 1], lastPeer = [...said].reverse().find((m) => m.sender !== "me");
13798
13852
  if (last) this.lastMessageAt.set(group.id, last.timestamp);
13799
- if (lastPeer) this.lastPeerMessageAt.set(group.id, lastPeer.timestamp);
13853
+ const peerAt = cameOrWritten(lastPeer?.timestamp, lastPeer ? group.came?.peer : void 0);
13854
+ if (peerAt) this.lastPeerMessageAt.set(group.id, peerAt);
13800
13855
  const mention = [...history].reverse().find((m) => m.mentioned);
13801
- if (mention) this.lastMentionAt.set(group.id, mention.timestamp);
13856
+ const mentionAt = cameOrWritten(mention?.timestamp, mention ? group.came?.mention : void 0);
13857
+ if (mentionAt) this.lastMentionAt.set(group.id, mentionAt);
13802
13858
  for (const [, linkId] of this.host.entries(group.id)) if (group.joining?.linkId !== linkId) await this.host.closeEdge(linkId);
13803
13859
  const live = this.live.get(group.id);
13804
13860
  if (live) for (const key of this.host.edges(group.id).keys()) live.restored.set(key, 0);
@@ -13821,7 +13877,7 @@ var Communities = class {
13821
13877
  invited: [],
13822
13878
  memberLinks: {}
13823
13879
  };
13824
- if (group.joining && (!live || live.session.status === "lost")) return [{
13880
+ if (this.knocking(group, live)) return [{
13825
13881
  ...base,
13826
13882
  name: groupName(group.joining.name) ?? live?.session.name ?? "",
13827
13883
  isAdmin: false,
@@ -13834,7 +13890,8 @@ var Communities = class {
13834
13890
  members: 0,
13835
13891
  accepted: true,
13836
13892
  viaLink: true,
13837
- stage: this.joinStage(group)
13893
+ stage: this.joinStage(group),
13894
+ ...this.knockingWith.get(group.id) ? { waiting: this.knockingWith.get(group.id) } : {}
13838
13895
  }
13839
13896
  }];
13840
13897
  if (!live) return [];
@@ -13843,7 +13900,7 @@ var Communities = class {
13843
13900
  const id = edges.get(key);
13844
13901
  return !!id && this.host.linkReady(id, 2);
13845
13902
  };
13846
- const hubs = freshHubs(live.beacon, this.now()).map((h) => h.key);
13903
+ const hubs = freshHubs(this.hubs(live), this.now()).map((h) => h.key);
13847
13904
  return [{
13848
13905
  ...base,
13849
13906
  name: s.name,
@@ -13904,17 +13961,36 @@ var Communities = class {
13904
13961
  if (!link) throw new Error("This is not a link to a group");
13905
13962
  const existing = this.stored.get(link.g);
13906
13963
  const live = this.live.get(link.g);
13907
- if (live && live.session.status === "active") return link.g;
13908
13964
  if (existing?.joining && existing.joining.host === link.host) return link.g;
13965
+ if (live) {
13966
+ const s = live.session;
13967
+ if (s.status === "active" && s.roster.length === 1) return link.g;
13968
+ if (s.status === "active") {
13969
+ await this.startJoining(link, s.state.seedB64, this.now(), true);
13970
+ return link.g;
13971
+ }
13972
+ if (s.status === "removed" || s.status === "lost") {
13973
+ await this.startJoining(link, s.state.seedB64, this.now());
13974
+ return link.g;
13975
+ }
13976
+ }
13909
13977
  if (existing) await this.forget(link.g);
13910
13978
  await this.startJoining(link, dialedKey(link), this.now());
13911
13979
  return link.g;
13912
13980
  }
13913
- async startJoining(link, seedB64, since) {
13981
+ /** 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. */
13982
+ knocking(group, live) {
13983
+ return !!group.joining && (!live || live.session.status !== "active");
13984
+ }
13985
+ /** `check`: a member, as far as this device knows, asking the door whether it still is (`joinByLink`). */
13986
+ async startJoining(link, seedB64, since, check = false) {
13914
13987
  this.lastKnock.delete(link.g);
13915
13988
  this.knocked.delete(link.g);
13916
13989
  this.knockAt.delete(link.g);
13990
+ this.knockingWith.delete(link.g);
13991
+ const before = this.stored.get(link.g)?.joining;
13917
13992
  const linkId = await this.host.openEntry(link, "guest", seedB64, link.host);
13993
+ if (before && before.linkId !== linkId) await this.host.closeEdge(before.linkId);
13918
13994
  const group = this.stored.get(link.g) ?? {
13919
13995
  id: link.g,
13920
13996
  createdAt: since
@@ -13927,7 +14003,8 @@ var Communities = class {
13927
14003
  name: group.community?.name ?? "",
13928
14004
  inviter: "",
13929
14005
  pieces: [],
13930
- since
14006
+ since,
14007
+ ...check && { check: true }
13931
14008
  };
13932
14009
  this.stored.set(link.g, group);
13933
14010
  await this.store.putGroup(group);
@@ -13935,9 +14012,29 @@ var Communities = class {
13935
14012
  traceJoin(link.g, "join.start");
13936
14013
  this.knock(group, since).catch(() => {});
13937
14014
  }
14015
+ /** The check is over (answered, or nobody was at the door): the group stays as it is here. */
14016
+ async endCheck(group) {
14017
+ const joining = group.joining;
14018
+ if (!joining) return;
14019
+ delete group.joining;
14020
+ this.lastKnock.delete(group.id);
14021
+ this.knocked.delete(group.id);
14022
+ this.knockAt.delete(group.id);
14023
+ this.knockingWith.delete(group.id);
14024
+ await this.host.closeEdge(joining.linkId);
14025
+ await this.store.putGroup(group);
14026
+ this.host.emit();
14027
+ }
13938
14028
  async send(groupId, text, mentions = [], reply, forwarded, card) {
13939
14029
  const live = this.live.get(groupId);
13940
- if (!live) return { error: "You are not in this group yet" };
14030
+ if (!live) return {
14031
+ error: "You are not in this group yet",
14032
+ refused: true
14033
+ };
14034
+ if (!live.session.isMember) return {
14035
+ error: live.session.state.statusReason ?? "You are not in this group",
14036
+ refused: true
14037
+ };
13941
14038
  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
14039
  return "error" in result ? { error: result.error } : {
13943
14040
  error: null,
@@ -14050,7 +14147,7 @@ var Communities = class {
14050
14147
  */
14051
14148
  async decline(groupId) {
14052
14149
  const group = this.stored.get(groupId), live = this.live.get(groupId);
14053
- if (!group?.joining || live && live.session.status !== "lost") throw new Error("No invitation to decline");
14150
+ if (!group || !this.knocking(group, live)) throw new Error("No invitation to decline");
14054
14151
  if (live) throw new Error("You were in this group: leave it to stop trying to get back in");
14055
14152
  await this.forget(groupId);
14056
14153
  }
@@ -14062,6 +14159,7 @@ var Communities = class {
14062
14159
  this.lastMessageAt.delete(groupId);
14063
14160
  this.lastPeerMessageAt.delete(groupId);
14064
14161
  this.lastMentionAt.delete(groupId);
14162
+ this.knockingWith.delete(groupId);
14065
14163
  for (const linkId of [...this.host.edges(groupId).values(), ...this.host.entries(groupId).values()]) await this.host.closeEdge(linkId);
14066
14164
  await this.store.deleteGroup(groupId);
14067
14165
  this.host.historyGone?.(groupId);
@@ -14077,9 +14175,10 @@ var Communities = class {
14077
14175
  this.time = now;
14078
14176
  for (const group of [...this.stored.values()]) {
14079
14177
  const live = this.live.get(group.id);
14080
- if (group.joining && (!live || live.session.status === "lost")) {
14081
- const every = now - group.joining.since > this.timings.patienceMs ? this.timings.slowKnockMs : this.timings.knockMs;
14082
- 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(() => {});
14178
+ if (group.joining && (this.knocking(group, live) || group.joining.check)) {
14179
+ const waited = now - group.joining.since, every = waited > this.timings.patienceMs ? this.timings.slowKnockMs : this.timings.knockMs;
14180
+ if (!this.knocking(group, live) && waited > CHECK_MS) await this.endCheck(group);
14181
+ 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
14182
  if (!live) continue;
14084
14183
  }
14085
14184
  if (!live) continue;
@@ -14096,6 +14195,12 @@ var Communities = class {
14096
14195
  continue;
14097
14196
  }
14098
14197
  if (live.session.status !== "active") {
14198
+ if (live.hub) {
14199
+ live.hub = false;
14200
+ await this.closeEntries(group.id);
14201
+ await this.publishBeacon(group.id, live, now, false).catch(() => {});
14202
+ }
14203
+ live.farewells.clear();
14099
14204
  await this.reconcile(group.id, live, now);
14100
14205
  continue;
14101
14206
  }
@@ -14111,14 +14216,19 @@ var Communities = class {
14111
14216
  live.lingering.delete(linkId);
14112
14217
  if ([...this.host.entries(groupId).values()].includes(linkId)) await this.host.closeEdge(linkId);
14113
14218
  }
14114
- const alone = live.hub && !freshHubs(live.beacon, now).some((h) => h.key !== me);
14115
- if (now - live.lastBeaconRead >= (alone ? COMMUNITY_TOPOLOGY.beaconEveryMs : this.timings.beaconReadMs) || live.lastBeaconRead === 0) await this.readBeacon(groupId, live, now);
14116
- const others = freshHubs(live.beacon, now).filter((h) => h.key !== me);
14219
+ const alone = live.hub && !freshHubs(this.hubs(live), now).some((h) => h.key !== me);
14220
+ 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);
14221
+ if (!live.beaconKnown) {
14222
+ await this.keepLooking(groupId, live, now);
14223
+ await this.reconcile(groupId, live, now);
14224
+ return;
14225
+ }
14226
+ const others = freshHubs(this.hubs(live), now).filter((h) => h.key !== me);
14117
14227
  if (!live.hub) {
14118
- const settled = () => now - live.joinedAt >= this.timings.newcomerMs || !freshHubs(live.beacon, now).some((h) => h.key !== me);
14119
- const want = () => settled() && (shouldBeHub(me, live.beacon, now) || !!live.forceHub && freshHubs(live.beacon, now).length < COMMUNITY_TOPOLOGY.maxHubs) && this.capacity(groupId, live, now) > 0;
14228
+ const settled = () => now - live.joinedAt >= this.timings.newcomerMs || !freshHubs(this.hubs(live), now).some((h) => h.key !== me);
14229
+ 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
14230
  if (want()) {
14121
- if (!live.hubCandidateAt) live.hubCandidateAt = now + (freshHubs(live.beacon, now).length ? Math.floor(this.random() * this.timings.hubJitterMs) : 0);
14231
+ if (!live.hubCandidateAt) live.hubCandidateAt = now + (freshHubs(this.hubs(live), now).length ? Math.floor(this.random() * this.timings.hubJitterMs) : 0);
14122
14232
  if (now >= live.hubCandidateAt) {
14123
14233
  live.hubCandidateAt = 0;
14124
14234
  if (live.lastBeaconRead !== now) await this.readBeacon(groupId, live, now);
@@ -14144,6 +14254,10 @@ var Communities = class {
14144
14254
  await this.publishBeacon(groupId, live, now, false);
14145
14255
  } 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(() => {});
14146
14256
  }
14257
+ if (live.hub) {
14258
+ 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);
14259
+ }
14260
+ this.endFarewells(live, now);
14147
14261
  if (live.hub) {
14148
14262
  const busy = this.admitting(groupId, live, now);
14149
14263
  const lobbyEvery = now < live.lobbyBusyUntil && !busy ? this.timings.lobbyPollMs : this.timings.lobbyIdlePollMs;
@@ -14200,7 +14314,7 @@ var Communities = class {
14200
14314
  }
14201
14315
  if (s.needsCatchUp && now - (live.lastCatchUp ?? 0) >= 5e3 || now - (live.lastCatchUp ?? 0) >= 3e4) {
14202
14316
  live.lastCatchUp = now;
14203
- const ready = [...this.host.edges(groupId)].filter(([, id]) => this.host.linkReady(id, 2)).map(([key]) => key);
14317
+ const ready = [...this.host.edges(groupId)].filter(([key, id]) => this.host.linkReady(id, 2) && !s.wasRemoved(key)).map(([key]) => key);
14204
14318
  for (const key of ready.slice(0, 3)) s.catchUp(key);
14205
14319
  }
14206
14320
  await this.reconcile(groupId, live, now);
@@ -14258,7 +14372,7 @@ var Communities = class {
14258
14372
  capacity(groupId, live, now, entries = true) {
14259
14373
  const room = this.host.peerRoom?.(groupId);
14260
14374
  if (room === void 0) return COMMUNITY_TOPOLOGY.hubCapacity;
14261
- const hubs = new Set(freshHubs(live.beacon, now).map((h) => h.key));
14375
+ const hubs = new Set(freshHubs(this.hubs(live), now).map((h) => h.key));
14262
14376
  for (const key of this.host.edges(groupId).keys()) if (!live.members.has(key)) hubs.add(key);
14263
14377
  hubs.delete(live.session.myKey);
14264
14378
  const held = hubs.size + (entries ? this.host.entries(groupId).size : 0);
@@ -14272,7 +14386,15 @@ var Communities = class {
14272
14386
  async readBeacon(groupId, live, now) {
14273
14387
  live.lastBeaconRead = now;
14274
14388
  const keys = beaconKeys(live.session.state.rv, groupId);
14275
- const records = await this.host.resolve(keys.identity.pubKeyZ32, true).catch(() => null);
14389
+ const records = await this.host.resolve(keys.identity.pubKeyZ32, true).catch(() => void 0);
14390
+ if (records === void 0) {
14391
+ live.beaconFailedAt = now;
14392
+ live.beaconAskedAt ||= now;
14393
+ if (live.session.roster.length === 1 || now - live.beaconAskedAt >= COMMUNITY_TOPOLOGY.beaconFreshMs) live.beaconKnown = true;
14394
+ return;
14395
+ }
14396
+ live.beaconFailedAt = 0;
14397
+ live.beaconKnown = true;
14276
14398
  live.beacon = readBeacon(keys, records ?? []);
14277
14399
  live.head = readBeaconHead(keys, records ?? []);
14278
14400
  this.noteHubs(live, now);
@@ -14296,8 +14418,10 @@ var Communities = class {
14296
14418
  live.lastBeaconTry = now;
14297
14419
  const keys = beaconKeys(live.session.state.rv, groupId);
14298
14420
  let existing = live.beacon, head = live.head;
14299
- if (live.lastBeaconRead !== now) {
14300
- const records = await this.host.resolve(keys.identity.pubKeyZ32, true).catch(() => null) ?? [];
14421
+ if (live.lastBeaconRead !== now || live.beaconFailedAt === now) {
14422
+ const read = await this.host.resolve(keys.identity.pubKeyZ32, true).catch(() => void 0);
14423
+ if (read === void 0 && live.session.roster.length > 1) throw new Error("The beacon could not be read; not published");
14424
+ const records = read ?? [];
14301
14425
  existing = readBeacon(keys, records);
14302
14426
  head = readBeaconHead(keys, records);
14303
14427
  }
@@ -14314,9 +14438,74 @@ var Communities = class {
14314
14438
  this.noteHubs(live, now);
14315
14439
  live.lastBeaconWrite = now;
14316
14440
  live.lastBeaconRead = now;
14441
+ live.beaconFailedAt = 0;
14442
+ live.beaconKnown = true;
14443
+ }
14444
+ /**
14445
+ * The beacon's hubs, less those the chain took out: hubs are members. Someone removed while its app was closed lists
14446
+ * itself there when it comes back, believing it is a member, until it is told (`wantFarewell`).
14447
+ */
14448
+ hubs(live) {
14449
+ const top = live.session.top, memo = live.hubsMemo;
14450
+ if (memo && memo.beacon === live.beacon && memo.top === top) return memo.hubs;
14451
+ const hubs = live.beacon.filter((h) => !live.session.wasRemoved(h.key));
14452
+ live.hubsMemo = {
14453
+ beacon: live.beacon,
14454
+ top,
14455
+ hubs
14456
+ };
14457
+ return hubs;
14458
+ }
14459
+ /** A frame goes on an edge to a member; to someone the chain took out, only the commit that took it out. */
14460
+ mayHear(live, key, frame) {
14461
+ if (!live?.session.wasRemoved(key)) return true;
14462
+ const f = frame;
14463
+ return f.t === "group-commit" && (f.commit?.k === "remove" || f.commit?.k === "leave") && f.commit.s === key;
14464
+ }
14465
+ /** The edges of the group that may hear `frame`. */
14466
+ hearers(groupId, frame) {
14467
+ const live = this.live.get(groupId);
14468
+ return [...this.host.edges(groupId)].filter(([key]) => this.mayHear(live, key, frame));
14469
+ }
14470
+ /** 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. */
14471
+ wantFarewell(groupId, live, key, now) {
14472
+ if (key === live.session.myKey || live.farewells.has(key) || now < (live.farewellQuiet.get(key) ?? 0)) return;
14473
+ if (live.farewells.size >= FAREWELL_EDGES || this.capacity(groupId, live, now) <= 0) return;
14474
+ live.farewells.set(key, { since: now });
14475
+ if (!this.host.edges(groupId).has(key)) live.expect.add(key);
14476
+ }
14477
+ /** Farewell edges end: told a moment ago, never came, or back in the group; and a hub no more keeps none. */
14478
+ endFarewells(live, now) {
14479
+ for (const [key, f] of live.farewells) {
14480
+ const back = !live.session.wasRemoved(key);
14481
+ if (!back && live.hub && (f.told ? now - f.told < FAREWELL_LINGER_MS : now - f.since < FAREWELL_WAIT_MS)) continue;
14482
+ live.farewells.delete(key);
14483
+ if (!back) live.farewellQuiet.set(key, now + (f.told ? FAREWELL_TOLD_MS : FAREWELL_RETRY_MS));
14484
+ }
14485
+ for (const [key, until] of live.farewellQuiet) if (until <= now) live.farewellQuiet.delete(key);
14486
+ for (const [key, at] of live.farewellSaid) if (now - at >= FAREWELL_SAY_MS) live.farewellSaid.delete(key);
14487
+ }
14488
+ /**
14489
+ * Someone the chain took out says where it is (`group-sync`): it is handed the commits up to the one that took it
14490
+ * out, and nothing else (`CommunitySession.farewell`). Nothing else it says is heard.
14491
+ */
14492
+ sayFarewell(groupId, live, key, sync) {
14493
+ const now = this.now(), linkId = this.host.edges(groupId).get(key);
14494
+ if (!linkId || now - (live.farewellSaid.get(key) ?? -Infinity) < FAREWELL_SAY_MS) return;
14495
+ const frames = live.session.farewell(key, sync);
14496
+ if (!frames.length) return;
14497
+ live.farewellSaid.set(key, now);
14498
+ try {
14499
+ for (const frame of frames) this.host.sendOnLink(linkId, frame);
14500
+ } catch {
14501
+ return;
14502
+ }
14503
+ const waited = live.farewells.get(key);
14504
+ if (waited) waited.told = now;
14505
+ traceJoin(groupId, "farewell.sent");
14317
14506
  }
14318
14507
  noteHubs(live, now) {
14319
- for (const h of freshHubs(live.beacon, now)) live.seenHubs.set(h.key, now);
14508
+ for (const h of freshHubs(this.hubs(live), now)) live.seenHubs.set(h.key, now);
14320
14509
  for (const [key, at] of live.seenHubs) if (now - at > HUB_GRACE_MS) live.seenHubs.delete(key);
14321
14510
  }
14322
14511
  recentHub(live, key) {
@@ -14336,7 +14525,11 @@ var Communities = class {
14336
14525
  const keys = lobbyKeys(live.session.state.rv, groupId, live.session.myKey);
14337
14526
  const entries = readLobby(keys, await this.host.resolve(keys.identity.pubKeyZ32, true).catch(() => null) ?? []);
14338
14527
  for (const { key, ts } of entries) {
14339
- if (now - ts > COMMUNITY_TOPOLOGY.lobbyFreshMs || key === live.session.myKey || live.session.wasRemoved(key)) continue;
14528
+ if (now - ts > COMMUNITY_TOPOLOGY.lobbyFreshMs || key === live.session.myKey) continue;
14529
+ if (live.session.wasRemoved(key)) {
14530
+ if (ts + FAREWELL_SKEW_MS > (live.session.outAt(key) ?? Infinity)) this.wantFarewell(groupId, live, key, now);
14531
+ continue;
14532
+ }
14340
14533
  live.emptySince = now;
14341
14534
  if (!live.members.has(key) && live.members.size >= this.capacity(groupId, live, now)) continue;
14342
14535
  if (!live.members.has(key)) {
@@ -14353,10 +14546,11 @@ var Communities = class {
14353
14546
  if (s.status === "active") {
14354
14547
  if (live.hub) {
14355
14548
  this.shed(groupId, live, now);
14356
- for (const h of freshHubs(live.beacon, now)) if (h.key !== s.myKey) wanted.add(h.key);
14549
+ for (const h of freshHubs(this.hubs(live), now)) if (h.key !== s.myKey) wanted.add(h.key);
14357
14550
  for (const key of live.members.keys()) wanted.add(key);
14551
+ for (const key of live.farewells.keys()) wanted.add(key);
14358
14552
  } 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);
14553
+ 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
14554
  }
14361
14555
  for (const [key, since] of live.restored) {
14362
14556
  if (!since) live.restored.set(key, now);
@@ -14411,7 +14605,7 @@ var Communities = class {
14411
14605
  g: groupId,
14412
14606
  host: s.entryKey
14413
14607
  };
14414
- const hubs = doorHubs(live.beacon, now, s.myKey);
14608
+ const hubs = doorHubs(this.hubs(live), now, s.myKey);
14415
14609
  if (!hubs.includes(s.myKey)) return;
14416
14610
  const door = [...hubs].sort()[0], doorSig = [...hubs].sort().join(",");
14417
14611
  if (live.doors !== doorSig) {
@@ -14433,7 +14627,7 @@ var Communities = class {
14433
14627
  ts
14434
14628
  }));
14435
14629
  for (const { key, ts } of knocks) {
14436
- if (now - ts > 18e4 || this.refused.has(key) || live.pendingEntries.has(key) || key === link.host) continue;
14630
+ if (now - ts > 18e4 || this.refused.has(key) || live.pendingEntries.has(key) || key === link.host || key === s.myKey) continue;
14437
14631
  const stillKnocking = now - ts < 2 * this.timings.slowKnockMs;
14438
14632
  if (rosterHas(s.roster, key)) {
14439
14633
  const added = [...s.state.chain].reverse().find((c) => c.k === "add" && c.s === key)?.ts ?? 0;
@@ -14510,7 +14704,10 @@ var Communities = class {
14510
14704
  ms: Date.now() - started,
14511
14705
  record: n
14512
14706
  });
14513
- if (!this.knocked.has(group.id)) {
14707
+ const others = knocks.filter((k) => k.key !== me && now - k.ts < 2 * this.timings.slowKnockMs).length;
14708
+ const moved = (this.knockingWith.get(group.id) ?? 0) !== others;
14709
+ this.knockingWith.set(group.id, others);
14710
+ if (!this.knocked.has(group.id) || moved) {
14514
14711
  this.knocked.add(group.id);
14515
14712
  this.host.emit();
14516
14713
  }
@@ -14602,7 +14799,16 @@ var Communities = class {
14602
14799
  return;
14603
14800
  }
14604
14801
  traceJoin(g, "welcome.received");
14605
- const again = !!this.live.get(g);
14802
+ const before = this.live.get(g)?.session, again = !!before;
14803
+ if (before?.status === "active" && before.isMember) {
14804
+ const mine = (chain) => [...chain].reverse().find((c) => c.k === "add" && c.s === before.myKey)?.sig;
14805
+ if (mine(joined.state.chain) === mine(before.state.chain)) {
14806
+ await this.endCheck(group);
14807
+ return;
14808
+ }
14809
+ await this.event(g, "removed", "You were removed from this group", this.now(), joined.state.chain.length - 1);
14810
+ }
14811
+ const back = !!before && before.status !== "lost";
14606
14812
  for (const [, id] of this.host.entries(g)) if (id !== linkId) await this.host.closeEdge(id);
14607
14813
  const member = {
14608
14814
  id: g,
@@ -14620,9 +14826,11 @@ var Communities = class {
14620
14826
  live.joinedAt = now;
14621
14827
  await this.store.putGroup(member);
14622
14828
  if (!again) await this.event(g, "joined", `You joined. ${GROUP_READ_NOTE_COMMUNITY}`, now, joined.state.chain.length - 1);
14829
+ else if (back) await this.event(g, "joined", "You joined again", now, joined.state.chain.length - 1);
14623
14830
  this.lastKnock.delete(g);
14624
14831
  this.knocked.delete(g);
14625
14832
  this.knockAt.delete(g);
14833
+ this.knockingWith.delete(g);
14626
14834
  await this.host.closeEdge(linkId);
14627
14835
  await this.reconcile(g, live, now);
14628
14836
  this.host.emit();
@@ -14642,12 +14850,17 @@ var Communities = class {
14642
14850
  /** A frame on an edge, from the member it is pinned to: handled, then relayed on by a hub. */
14643
14851
  async handleEdgeFrame(groupId, peerKey, frame) {
14644
14852
  const live = this.live.get(groupId);
14645
- if (!live || !frame || typeof frame !== "object" || live.session.wasRemoved(peerKey)) return;
14853
+ if (!live || !frame || typeof frame !== "object") return;
14646
14854
  const f = frame;
14855
+ if (live.session.wasRemoved(peerKey)) {
14856
+ if (f.t === "group-sync" && live.session.isMember) this.sayFarewell(groupId, live, peerKey, f);
14857
+ return;
14858
+ }
14647
14859
  if ((f.t === "group-secret" || f.t === "group-entry") && typeof f.to === "string" && f.to !== live.session.myKey) {
14648
14860
  if (!live.hub) return;
14649
14861
  const id = `${String(f.t)}:${f.to}:${String(f.h ?? f.x)}`;
14650
14862
  if (!this.remember(live, id)) return;
14863
+ if (live.session.wasRemoved(f.to)) return;
14651
14864
  const edges = this.host.edges(groupId), direct = edges.get(f.to);
14652
14865
  if (direct && this.host.linkReady(direct, 2)) {
14653
14866
  this.sendTo(direct, f);
@@ -14657,10 +14870,10 @@ var Communities = class {
14657
14870
  return;
14658
14871
  }
14659
14872
  if (!await live.session.handle(peerKey, frame) || !live.hub) return;
14660
- for (const [key, id] of this.host.edges(groupId)) if (key !== peerKey) this.sendTo(id, f);
14873
+ for (const [key, id] of this.hearers(groupId, f)) if (key !== peerKey) this.sendTo(id, f);
14661
14874
  }
14662
14875
  peerIsHub(live, key) {
14663
- return this.recentHub(live, key) && !live.members.has(key);
14876
+ return this.recentHub(live, key) && !live.members.has(key) && !live.session.wasRemoved(key);
14664
14877
  }
14665
14878
  remember(live, id) {
14666
14879
  if (live.relayed.has(id)) return false;
@@ -14711,21 +14924,24 @@ var Communities = class {
14711
14924
  await this.store.putGroup(group);
14712
14925
  },
14713
14926
  broadcast: (frame) => {
14714
- for (const linkId of this.host.edges(id).values()) this.sendTo(linkId, frame);
14927
+ for (const [, linkId] of this.hearers(id, frame)) this.sendTo(linkId, frame);
14928
+ },
14929
+ direct: (to, frame) => {
14930
+ if (this.mayHear(this.live.get(id), to, frame)) this.sendTo(this.host.edges(id).get(to), frame);
14715
14931
  },
14716
- direct: (to, frame) => this.sendTo(this.host.edges(id).get(to), frame),
14717
14932
  addressed: (to, frame) => {
14718
14933
  const edges = this.host.edges(id), live = this.live.get(id);
14719
14934
  const direct = edges.get(to);
14935
+ if (live?.session.wasRemoved(to)) return;
14720
14936
  if (direct && this.host.linkReady(direct, 2)) {
14721
14937
  this.sendTo(direct, frame);
14722
14938
  return;
14723
14939
  }
14724
14940
  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);
14941
+ for (const [key, linkId] of edges) if ((hubs.has(key) || live?.myHubs.includes(key)) && !live?.session.wasRemoved(key)) this.sendTo(linkId, frame);
14726
14942
  },
14727
14943
  message: async (m) => {
14728
- const timestamp = receivedTimestamp(m.timestamp);
14944
+ const timestamp = m.timestamp;
14729
14945
  const mentioned = m.sender !== session.myKey && mentionsMember(m.mentions, session.myKey);
14730
14946
  const stored = await this.host.storeMessage({
14731
14947
  linkId: MESSAGE_LINK$1(id),
@@ -14740,26 +14956,28 @@ var Communities = class {
14740
14956
  ...m.forwarded && { forwarded: m.forwarded },
14741
14957
  ...m.card && { card: m.card }
14742
14958
  });
14743
- this.lastMessageAt.set(id, Math.max(this.lastMessageAt.get(id) ?? 0, timestamp));
14744
- const came = cameAt(timestamp, stored, this.now());
14959
+ const came = m.sender === session.myKey ? timestamp : cameAt(timestamp, stored, this.now());
14960
+ this.lastMessageAt.set(id, Math.max(this.lastMessageAt.get(id) ?? 0, came));
14745
14961
  if (mentioned) this.lastMentionAt.set(id, Math.max(this.lastMentionAt.get(id) ?? 0, came));
14746
14962
  if (m.sender !== session.myKey) this.lastPeerMessageAt.set(id, Math.max(this.lastPeerMessageAt.get(id) ?? 0, came));
14963
+ noteCame(this.stored.get(id), came, m.sender !== session.myKey, mentioned);
14747
14964
  },
14748
14965
  app: (m) => this.deliver(id, () => this.host.communityApp?.(id, m.sender, m.frame)),
14749
14966
  pair: (m) => this.deliver(id, () => this.host.communityPair?.(id, m.sender, m.payload)),
14750
14967
  changed: () => {
14751
14968
  this.membershipChanged(id);
14752
14969
  },
14753
- metaChanged: (by, change) => {
14970
+ metaChanged: (by, change, at) => {
14754
14971
  const name = by === session.myKey ? "You" : session.state.nicks[by] ?? `Member ${by.slice(0, 8)}`;
14972
+ const when = receivedTimestamp(at, this.now());
14755
14973
  (async () => {
14756
- for (const line of metaLines(name, change, session.name)) await this.event(id, line.event, line.text, this.now(), session.epoch, by);
14974
+ for (const line of metaLines(name, change, session.name)) await this.event(id, line.event, line.text, when, session.epoch, by);
14757
14975
  this.host.emit();
14758
14976
  })();
14759
14977
  },
14760
14978
  clock: () => this.now(),
14761
14979
  relay: (frame) => {
14762
- if (this.live.get(id)?.hub) for (const linkId of this.host.edges(id).values()) this.sendTo(linkId, frame);
14980
+ if (this.live.get(id)?.hub) for (const [, linkId] of this.hearers(id, frame)) this.sendTo(linkId, frame);
14763
14981
  }
14764
14982
  });
14765
14983
  this.live.set(id, {
@@ -14769,6 +14987,8 @@ var Communities = class {
14769
14987
  beacon: [],
14770
14988
  head: null,
14771
14989
  lastBeaconRead: 0,
14990
+ beaconKnown: false,
14991
+ beaconFailedAt: 0,
14772
14992
  lastBeaconWrite: 0,
14773
14993
  lastBeaconTry: 0,
14774
14994
  hubCandidateAt: 0,
@@ -14786,6 +15006,9 @@ var Communities = class {
14786
15006
  lingering: /* @__PURE__ */ new Map(),
14787
15007
  seenHubs: /* @__PURE__ */ new Map(),
14788
15008
  restored: /* @__PURE__ */ new Map(),
15009
+ farewells: /* @__PURE__ */ new Map(),
15010
+ farewellQuiet: /* @__PURE__ */ new Map(),
15011
+ farewellSaid: /* @__PURE__ */ new Map(),
14789
15012
  knocksScanned: false,
14790
15013
  knockCursor: 0,
14791
15014
  lastShardPoll: 0,
@@ -14814,22 +15037,17 @@ var Communities = class {
14814
15037
  if (statusChanged && s.status === "removed") await this.event(groupId, "removed", s.state.statusReason ?? "You were removed from this group", when, s.epoch);
14815
15038
  else if (statusChanged && s.status === "forked") await this.event(groupId, "forked", s.state.statusReason ?? "The membership history forked", when, s.epoch);
14816
15039
  else if (s.status === "active" && before !== after) {
14817
- for (const [key] of after) if (!rosterHas(before, key) && key !== s.myKey) await this.event(groupId, "joined", `${name(key)} joined`, when, top.e, key);
14818
- for (const [key] of before) if (!rosterHas(after, key)) await this.event(groupId, "gone", `${name(key)} is no longer a member`, when, top.e, key);
14819
- 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`, when, top.e, top.s);
14820
- if (top.k === "rotate") await this.event(groupId, "rotated", "Keys rotated: a fresh epoch", when, top.e);
15040
+ const at = receivedTimestamp(top.ts, when);
15041
+ for (const [key] of after) if (!rosterHas(before, key) && key !== s.myKey) await this.event(groupId, "joined", `${name(key)} joined`, at, top.e, key);
15042
+ 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);
15043
+ 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);
15044
+ if (top.k === "rotate") await this.event(groupId, "rotated", "Keys rotated: a fresh epoch", at, top.e);
14821
15045
  }
14822
15046
  this.host.emit();
14823
15047
  }
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
- */
15048
+ /** A line in the group's history. Its id carries its time: two lines at the same moment get distinct ones (`eventTime`). */
14829
15049
  async event(groupId, event, text, timestamp, epoch, member) {
14830
- const last = this.lastEventAt.get(groupId) ?? 0;
14831
- if (timestamp <= last) timestamp = last + 1;
14832
- this.lastEventAt.set(groupId, Math.max(last, timestamp));
15050
+ timestamp = eventTime(this.eventTimes, groupId, timestamp);
14833
15051
  await this.host.storeMessage({
14834
15052
  linkId: MESSAGE_LINK$1(groupId),
14835
15053
  id: `event:${epoch}:${event}:${member ?? ""}:${timestamp}`,
@@ -14841,7 +15059,7 @@ var Communities = class {
14841
15059
  via: "datalink"
14842
15060
  });
14843
15061
  }
14844
- lastEventAt = /* @__PURE__ */ new Map();
15062
+ eventTimes = /* @__PURE__ */ new Map();
14845
15063
  };
14846
15064
  //#endregion
14847
15065
  //#region ../browser/src/engine/meshHubs.ts
@@ -15481,12 +15699,46 @@ var mentionAt = (map, groupId) => {
15481
15699
  return at ? { lastMentionAt: at } : {};
15482
15700
  };
15483
15701
  /**
15484
- * When a member's message counts as having come, for unread and the "@": when it reached me, if that is later than
15485
- * when it was written (a catch-up from another member, a member back after a while). Written before I last looked at
15486
- * the group but handed to me since, it is news here; by its own time it sorted among what I had read, and the group
15487
- * never showed unread. A copy I had already (`stored` false) or one completed in place keeps its own time.
15702
+ * When a message counts as having come, for the list of groups, unread and the "@": when it reached me, never when
15703
+ * its sender says it was written (a member's clock ahead would keep the group unread and on top; written before I
15704
+ * last looked but handed to me since, a catch-up is news here). A copy I had already (`stored` false) or one
15705
+ * completed in place moves nothing forward: its own time, now at the latest.
15488
15706
  */
15489
- var cameAt = (timestamp, stored, now) => stored === false ? timestamp : Math.max(timestamp, now);
15707
+ var cameAt = (timestamp, stored, now) => stored === false ? Math.min(timestamp, now) : now;
15708
+ /**
15709
+ * 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.
15710
+ * A line's id carries its time, and two lines can share a moment (two members gone in one change, a picture changed
15711
+ * then removed within a tick): the store would keep the first and drop the second as already there. Only a time
15712
+ * already taken moves: a line older than the last one written (a rename read after a later commit, in a catch-up)
15713
+ * keeps its place.
15714
+ */
15715
+ function eventTime(taken, groupId, timestamp) {
15716
+ let times = taken.get(groupId);
15717
+ if (!times) taken.set(groupId, times = /* @__PURE__ */ new Set());
15718
+ while (times.has(timestamp)) timestamp++;
15719
+ times.add(timestamp);
15720
+ if (times.size > 1024) times.delete(times.values().next().value);
15721
+ return timestamp;
15722
+ }
15723
+ /**
15724
+ * Keeps on the group's record when a member's message came, where that is later than what it holds: the record is
15725
+ * saved with the session right after (a message taken moves its state), so a message handed to me late still makes
15726
+ * the group unread after the app starts again. Read from the history alone, by its own time, it sorted among what I
15727
+ * had read and the mark was gone on the next start.
15728
+ */
15729
+ function noteCame(group, came, peer, mentioned) {
15730
+ if (!group) return;
15731
+ if (peer && came > (group.came?.peer ?? 0)) group.came = {
15732
+ ...group.came,
15733
+ peer: came
15734
+ };
15735
+ if (mentioned && came > (group.came?.mention ?? 0)) group.came = {
15736
+ ...group.came,
15737
+ mention: came
15738
+ };
15739
+ }
15740
+ /** What `load` starts the unread marks from: the history's own times, or when its latest came if that was later. */
15741
+ var cameOrWritten = (written, came) => written === void 0 && came === void 0 ? void 0 : Math.max(written ?? 0, came ?? 0);
15490
15742
  /** The view's `lastPeerMessageAt`, when there is one. */
15491
15743
  var peerMessageAt = (map, groupId) => {
15492
15744
  const at = map.get(groupId);
@@ -15500,6 +15752,7 @@ var ENTRY_TIMINGS = {
15500
15752
  slowKnockMs: 2e4,
15501
15753
  patienceMs: 12e4
15502
15754
  };
15755
+ var KNOCK_VERIFY_SPREAD_MS = 700;
15503
15756
  /** Entry sessions the admin runs at once; a joiner who does not finish in time is not answered again for a while. */
15504
15757
  var MAX_PENDING_ENTRIES = 4;
15505
15758
  var ENTRY_TIMEOUT_MS = 18e4;
@@ -15594,6 +15847,10 @@ var Groups = class {
15594
15847
  /** Admin side: until when a group's link is looked at the warm pace. */
15595
15848
  warmUntil = /* @__PURE__ */ new Map();
15596
15849
  lastKnock = /* @__PURE__ */ new Map();
15850
+ /** Joiner side: when a knock just written is read back (`KNOCK_VERIFY_MS`); none once it was found there. */
15851
+ knockCheckAt = /* @__PURE__ */ new Map();
15852
+ /** Joiner side: knocks written in a row without one found in the record since (`KNOCK_VERIFIES`). */
15853
+ knockRewrites = /* @__PURE__ */ new Map();
15597
15854
  /** Per group, members met over their admission a moment ago: their edge is opened expecting them. */
15598
15855
  justMet = /* @__PURE__ */ new Map();
15599
15856
  /** Joiner side: groups whose knock is published, for the stage the joiner is shown. */
@@ -15651,9 +15908,11 @@ var Groups = class {
15651
15908
  const history = await this.store.getMessages(MESSAGE_LINK(group.id)), said = history.filter((m) => !m.event && !m.groupPay);
15652
15909
  const last = said[said.length - 1], lastPeer = [...said].reverse().find((m) => m.sender !== "me");
15653
15910
  if (last) this.lastMessageAt.set(group.id, last.timestamp);
15654
- if (lastPeer) this.lastPeerMessageAt.set(group.id, lastPeer.timestamp);
15911
+ const peerAt = cameOrWritten(lastPeer?.timestamp, lastPeer ? group.came?.peer : void 0);
15912
+ if (peerAt) this.lastPeerMessageAt.set(group.id, peerAt);
15655
15913
  const mention = [...history].reverse().find((m) => m.mentioned);
15656
- if (mention) this.lastMentionAt.set(group.id, mention.timestamp);
15914
+ const mentionAt = cameOrWritten(mention?.timestamp, mention ? group.came?.mention : void 0);
15915
+ if (mentionAt) this.lastMentionAt.set(group.id, mentionAt);
15657
15916
  }
15658
15917
  for (const id of this.sessions.keys()) this.reconcileEdges(id);
15659
15918
  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);
@@ -16035,6 +16294,8 @@ var Groups = class {
16035
16294
  this.lastTold.delete(groupId);
16036
16295
  this.pendingEntries.delete(groupId);
16037
16296
  this.knocked.delete(groupId);
16297
+ this.knockCheckAt.delete(groupId);
16298
+ this.knockRewrites.delete(groupId);
16038
16299
  this.justMet.delete(groupId);
16039
16300
  this.relayAsked.delete(groupId);
16040
16301
  this.hubs.forget(groupId);
@@ -16103,7 +16364,7 @@ var Groups = class {
16103
16364
  if (existing?.state?.status === "active" || existing?.invitation?.entry === link.host) return link.g;
16104
16365
  if (existing?.invitation?.seedB64 && !existing.invitation.entry) throw new Error("You are already joining this group");
16105
16366
  if (existing) await this.forget(link.g);
16106
- const seedB64 = createIdentity().seedB64;
16367
+ const seedB64 = dialedKey(link);
16107
16368
  const linkId = await this.host.openEntry(link, "guest", seedB64, link.host);
16108
16369
  const group = {
16109
16370
  id: link.g,
@@ -16138,7 +16399,7 @@ var Groups = class {
16138
16399
  }
16139
16400
  if (group.invitation?.entry && !group.state) {
16140
16401
  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(() => {});
16402
+ 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
16403
  continue;
16143
16404
  }
16144
16405
  const session = this.sessions.get(group.id);
@@ -16251,15 +16512,29 @@ var Groups = class {
16251
16512
  host: invitation.entry
16252
16513
  }, identity = knockIdentity(link);
16253
16514
  const started = Date.now();
16515
+ this.knockCheckAt.delete(group.id);
16254
16516
  const existing = readKnocks(link, await this.host.resolve(identity.pubKeyZ32) ?? []);
16517
+ const key = identityFromSeedB64(invitation.seedB64).pubKeyZ32, mine = existing.find((k) => k.key === key);
16255
16518
  traceJoin(group.id, "knock.read", {
16256
16519
  ms: Date.now() - started,
16257
- others: existing.length
16520
+ others: existing.length,
16521
+ ...mine ? { mine: now - mine.ts } : {}
16258
16522
  });
16523
+ if (mine && now - mine.ts >= 0 && now - mine.ts < 3e4) {
16524
+ this.knockRewrites.delete(group.id);
16525
+ if (!this.knocked.has(group.id)) {
16526
+ this.knocked.add(group.id);
16527
+ this.host.emit();
16528
+ }
16529
+ return;
16530
+ }
16259
16531
  await this.host.publish(identity, knockRecords(link, mergeKnocks(existing, {
16260
- key: identityFromSeedB64(invitation.seedB64).pubKeyZ32,
16532
+ key,
16261
16533
  ts: now
16262
16534
  }, now)));
16535
+ const rewrites = (this.knockRewrites.get(group.id) ?? 0) + 1;
16536
+ this.knockRewrites.set(group.id, rewrites);
16537
+ if (rewrites <= 2) this.knockCheckAt.set(group.id, now + 800 + gossipStart(key) % KNOCK_VERIFY_SPREAD_MS);
16263
16538
  traceJoin(group.id, "knock.published", { ms: Date.now() - started });
16264
16539
  if (!this.knocked.has(group.id)) {
16265
16540
  this.knocked.add(group.id);
@@ -16745,7 +17020,7 @@ var Groups = class {
16745
17020
  }
16746
17021
  },
16747
17022
  message: async (m) => {
16748
- const timestamp = receivedTimestamp(m.timestamp);
17023
+ const timestamp = m.timestamp;
16749
17024
  const mentioned = m.sender !== session.myKey && mentionsMember(m.mentions, session.myKey);
16750
17025
  const message = {
16751
17026
  linkId: MESSAGE_LINK(state.id),
@@ -16761,10 +17036,11 @@ var Groups = class {
16761
17036
  ...m.card && { card: m.card }
16762
17037
  };
16763
17038
  const stored = m.completes && this.host.completeMessage ? (await this.host.completeMessage(message), false) : await this.host.storeMessage(message);
16764
- this.lastMessageAt.set(state.id, Math.max(this.lastMessageAt.get(state.id) ?? 0, timestamp));
16765
- const came = cameAt(timestamp, stored, this.now());
17039
+ const came = m.sender === session.myKey ? timestamp : cameAt(timestamp, stored, this.now());
17040
+ this.lastMessageAt.set(state.id, Math.max(this.lastMessageAt.get(state.id) ?? 0, came));
16766
17041
  if (mentioned) this.lastMentionAt.set(state.id, Math.max(this.lastMentionAt.get(state.id) ?? 0, came));
16767
17042
  if (m.sender !== session.myKey) this.lastPeerMessageAt.set(state.id, Math.max(this.lastPeerMessageAt.get(state.id) ?? 0, came));
17043
+ noteCame(this.stored.get(state.id), came, m.sender !== session.myKey, mentioned);
16768
17044
  this.typings.messageFrom(state.id, m.sender);
16769
17045
  },
16770
17046
  edit: (e) => this.host.groupEdit?.(state.id, e),
@@ -16772,8 +17048,8 @@ var Groups = class {
16772
17048
  if (session.status === "removed" && this.hubs.isHub(state.id) && !this.removedAt.has(state.id)) this.removedAt.set(state.id, this.now());
16773
17049
  this.membershipChanged(state.id);
16774
17050
  },
16775
- metaChanged: (by, change) => {
16776
- this.metaChanged(state.id, session, by, change);
17051
+ metaChanged: (by, change, at) => {
17052
+ this.metaChanged(state.id, session, by, change, at);
16777
17053
  }
16778
17054
  });
16779
17055
  this.sessions.set(state.id, session);
@@ -16795,7 +17071,7 @@ var Groups = class {
16795
17071
  top
16796
17072
  });
16797
17073
  const name = (key) => key === session.myKey ? "You" : session.state.nicks[key] ?? `Member ${key.slice(0, 8)}`;
16798
- const when = Date.now();
17074
+ const when = moved ? receivedTimestamp(top.ts) : Date.now();
16799
17075
  if (session.status === "removed") {
16800
17076
  if (statusChanged) await this.event(groupId, "removed", session.state.statusReason ?? "You were removed from this group", when, session.epoch);
16801
17077
  } else if (session.status === "forked") {
@@ -16817,9 +17093,11 @@ var Groups = class {
16817
17093
  this.host.membersChanged?.(groupId);
16818
17094
  this.host.emit();
16819
17095
  }
16820
- async metaChanged(groupId, session, by, change) {
17096
+ /** `at`: when the admin signed the statement (the line's time, as a commit's is its line's). */
17097
+ async metaChanged(groupId, session, by, change, at) {
16821
17098
  const name = by === session.myKey ? "You" : session.state.nicks[by] ?? `Member ${by.slice(0, 8)}`;
16822
- for (const line of metaLines(name, change, session.name)) await this.event(groupId, line.event, line.text, Date.now(), session.epoch, by);
17099
+ const when = at === void 0 ? Date.now() : receivedTimestamp(at);
17100
+ for (const line of metaLines(name, change, session.name)) await this.event(groupId, line.event, line.text, when, session.epoch, by);
16823
17101
  this.host.emit();
16824
17102
  }
16825
17103
  /**
@@ -16854,11 +17132,9 @@ var Groups = class {
16854
17132
  this.hubs.met(groupId, key, this.now());
16855
17133
  }
16856
17134
  /** `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 `Communities.event`. */
17135
+ /** A line in the group's history; lines at the same time (two members gone in one change) get distinct ones, see `eventTime`. */
16858
17136
  async event(groupId, event, text, timestamp, epoch, member) {
16859
- const last = this.lastEventAt.get(groupId) ?? 0;
16860
- if (timestamp <= last) timestamp = last + 1;
16861
- this.lastEventAt.set(groupId, Math.max(last, timestamp));
17137
+ timestamp = eventTime(this.eventTimes, groupId, timestamp);
16862
17138
  await this.host.storeMessage({
16863
17139
  linkId: MESSAGE_LINK(groupId),
16864
17140
  id: `event:${epoch}:${event}:${timestamp}`,
@@ -16870,7 +17146,7 @@ var Groups = class {
16870
17146
  via: "datalink"
16871
17147
  });
16872
17148
  }
16873
- lastEventAt = /* @__PURE__ */ new Map();
17149
+ eventTimes = /* @__PURE__ */ new Map();
16874
17150
  };
16875
17151
  //#endregion
16876
17152
  //#region ../browser/src/engine/groupEdges.ts
@@ -17379,7 +17655,7 @@ var GroupEdits = class {
17379
17655
  if (!takes(current) || (current.edit?.seq ?? 0) >= edit.e) return null;
17380
17656
  const next = withEdit(current, {
17381
17657
  seq: edit.e,
17382
- at: receivedTimestamp(edit.ts),
17658
+ at: heardTime(edit.ts),
17383
17659
  text: edit.m,
17384
17660
  card: edit.sc
17385
17661
  });
@@ -17876,6 +18152,14 @@ var POLL_IDLE_MS = 3e5;
17876
18152
  var RENEW_AFTER_MS = 3456e5;
17877
18153
  /** My pointer is published again this often while something is held, in case a relay forgot it. */
17878
18154
  var REPUBLISH_MS = 36e5;
18155
+ /**
18156
+ * A held item's address has this long to answer and be read whole, plus the time its declared size takes at the
18157
+ * slowest pace worth waiting for (64 KiB a second): about 21 s for a manifest, two and a half minutes for the largest
18158
+ * item. Past it the read is given up as any failed one is, and the next poll starts there again.
18159
+ */
18160
+ var HOLD_FETCH_MS = 2e4;
18161
+ var HOLD_FETCH_BYTES_PER_S = 65536;
18162
+ var holdFetchTimeoutMs = (max) => HOLD_FETCH_MS + Math.ceil(max / HOLD_FETCH_BYTES_PER_S) * 1e3;
17879
18163
  var hex$2 = (bytes) => Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
17880
18164
  /**
17881
18165
  * Store-and-forward for away contacts (WISP 4xx, `hold/1`), on both ends of every chat that turned it
@@ -18409,7 +18693,7 @@ var HoldEngine = class {
18409
18693
  });
18410
18694
  if (manifest.header.kind !== "manifest") throw new HoldRefusedError("format", "Not a manifest");
18411
18695
  mailbox = manifest.header.mailbox;
18412
- entries = readManifest(manifest.header.meta).filter(([seq]) => seq > this.state(linkId).inSeq);
18696
+ entries = readManifest(manifest.header.meta).filter(([seq]) => seq > this.state(linkId).inSeq).sort((a, b) => a[0] - b[0]);
18413
18697
  } catch (error) {
18414
18698
  if (error instanceof HoldRefusedError) {
18415
18699
  await this.save(linkId, {
@@ -18527,11 +18811,33 @@ var HoldEngine = class {
18527
18811
  }
18528
18812
  this.host.changed();
18529
18813
  }
18530
- /** A presigned address read whole, but never past `max` bytes: what the manifest declared, not what a server sends. */
18814
+ /**
18815
+ * A presigned address read whole, but never past `max` bytes: what the manifest declared, not what a server sends.
18816
+ * And never past `holdFetchTimeoutMs(max)`: a request that hangs (no answer, or a body that stops) is aborted and
18817
+ * fails as a network problem does. Before, one hung request held the pickup, and every chat's after it, for good.
18818
+ */
18531
18819
  async fetchBytes(url, max) {
18820
+ const abort = new AbortController();
18821
+ let timer;
18822
+ const late = new Promise((_, reject) => {
18823
+ timer = setTimeout(() => {
18824
+ reject(/* @__PURE__ */ new Error("The storage did not answer in time"));
18825
+ abort.abort();
18826
+ }, holdFetchTimeoutMs(max));
18827
+ });
18828
+ const read = this.readBytes(url, max, abort.signal);
18829
+ read.catch(() => {});
18830
+ try {
18831
+ return await Promise.race([read, late]);
18832
+ } finally {
18833
+ clearTimeout(timer);
18834
+ }
18835
+ }
18836
+ async readBytes(url, max, signal) {
18532
18837
  const response = await (this.host.fetch ?? ((...args) => fetch(...args)))(url, {
18533
18838
  cache: "no-store",
18534
- credentials: "omit"
18839
+ credentials: "omit",
18840
+ signal
18535
18841
  });
18536
18842
  if (!response.ok) throw new Error(`The storage answered ${response.status}`);
18537
18843
  if (Number(response.headers.get("content-length")) > max) throw new HoldRefusedError("size", "The held item is larger than the manifest said");
@@ -18541,6 +18847,9 @@ var HoldEngine = class {
18541
18847
  return bytes;
18542
18848
  }
18543
18849
  const reader = response.body.getReader();
18850
+ signal.addEventListener("abort", () => {
18851
+ reader.cancel().catch(() => {});
18852
+ }, { once: true });
18544
18853
  const chunks = [];
18545
18854
  let size = 0;
18546
18855
  for (let next = await reader.read(); !next.done; next = await reader.read()) {
@@ -23205,7 +23514,11 @@ var WalletSetup = class {
23205
23514
  * The wasm (about 1 MB gzip) loads on the first chat that starts an endpoint,
23206
23515
  * never on the app's first load.
23207
23516
  */
23208
- /** n0's public relays, the same ones the Desktop's Iroh homes on by default. */
23517
+ /**
23518
+ * n0's public relays, the same ones the Desktop's Iroh homes on by default. Iroh itself names them with the trailing
23519
+ * dot of a full domain name (`use1-1.relay.n0.iroh.link.`), and compares relay URLs as text; here they have none,
23520
+ * because WebKit opens no host name that ends in a dot. `BROWSER_SPELLING` is what keeps the two one relay.
23521
+ */
23209
23522
  var DEFAULT_IROH_RELAYS = [
23210
23523
  "https://use1-1.relay.n0.iroh.link/",
23211
23524
  "https://euc1-1.relay.n0.iroh.link/",
@@ -23213,6 +23526,12 @@ var DEFAULT_IROH_RELAYS = [
23213
23526
  "https://usw1-1.relay.n0.iroh.link/"
23214
23527
  ];
23215
23528
  /**
23529
+ * The spelling of every relay URL handed to the wasm Iroh (`irohRelayUrl`): the relays it homes on and a contact's
23530
+ * relay before a dial. One spelling, so a contact on this endpoint's relay is reached over the connection already
23531
+ * open to it; without the dot, so Safari can open it (a Desktop's record names its relay with the dot).
23532
+ */
23533
+ var BROWSER_SPELLING = "plain";
23534
+ /**
23216
23535
  * Why a relay URL cannot be used, or null. TLS, except on this machine (a local `iroh-relay --dev`): a page
23217
23536
  * may not open plain WebSockets elsewhere, and the relay's operator would see traffic metadata in the clear.
23218
23537
  */
@@ -23310,7 +23629,7 @@ var IrohWebChannel = class {
23310
23629
  async function createIrohWebEndpoint(seedB64, options = {}) {
23311
23630
  const seed = fromBase64Url(seedB64);
23312
23631
  if (seed.length !== 32) throw new Error("Invalid transport seed");
23313
- const relays = [...options.relays?.length ? options.relays : DEFAULT_IROH_RELAYS];
23632
+ const relays = [...new Set((options.relays?.length ? options.relays : DEFAULT_IROH_RELAYS).map((relay) => irohRelayUrl(relay, BROWSER_SPELLING)))];
23314
23633
  const node = await (await (options.load ?? loadIrohWasm)()).IrohNode.start(seed, relays, options.onlineMs ?? 1e4);
23315
23634
  const channels = /* @__PURE__ */ new Set();
23316
23635
  let stopped = false;
@@ -23340,7 +23659,7 @@ async function createIrohWebEndpoint(seedB64, options = {}) {
23340
23659
  },
23341
23660
  async connect(descriptor) {
23342
23661
  if (stopped) throw new Error("Iroh endpoint is stopped");
23343
- const conn = await node.connect(descriptor, options.connectMs ?? 2e4);
23662
+ const conn = await node.connect(irohDescriptor(descriptor, BROWSER_SPELLING), options.connectMs ?? 2e4);
23344
23663
  if (stopped) {
23345
23664
  conn.close();
23346
23665
  throw new Error("Iroh endpoint is stopped");
@@ -23520,12 +23839,29 @@ var GhostlyNode = class GhostlyNode {
23520
23839
  relays;
23521
23840
  /** Other profiles of this device, looked at for new messages (WISP 04 § Checking other profiles). */
23522
23841
  profilePeek;
23842
+ /** Whether this device's WebRTC attempts say direct connections are blocked on its network (`directPath.ts`). */
23843
+ directPath = new DirectPathWatch(() => {
23844
+ if (!this.shuttingDown) this.emitState();
23845
+ });
23846
+ /**
23847
+ * Edges of groups whose WebRTC did not connect in this run of the app (`edgeWithoutRtc`): they go on as an app with
23848
+ * no WebRTC does, over a native transport. Not kept: the next start tries WebRTC first again.
23849
+ */
23850
+ edgeRtcOff = /* @__PURE__ */ new Set();
23523
23851
  pollIntervals;
23524
23852
  localFetch;
23525
23853
  links = /* @__PURE__ */ new Map();
23526
23854
  services = [];
23527
23855
  activeLinkId = null;
23528
- nativeQueue = Promise.resolve();
23856
+ /**
23857
+ * Native listeners start one at a time per transport (`ensureNativeEndpoints`): their slots are counted per transport.
23858
+ * Not one queue for all: a transport slow to start held every other chat's listeners of every kind behind it.
23859
+ */
23860
+ nativeQueues = /* @__PURE__ */ new Map();
23861
+ /** Every listener start asked for so far has ended. */
23862
+ get nativeQueue() {
23863
+ return Promise.all(this.nativeQueues.values()).then(() => {});
23864
+ }
23529
23865
  shuttingDown = false;
23530
23866
  /** `start` has put the kept transfers back (EngineState.transfersRestored). */
23531
23867
  transfersRestored = false;
@@ -23551,11 +23887,16 @@ var GhostlyNode = class GhostlyNode {
23551
23887
  cueFeedback({ cue, key }, linkId) {
23552
23888
  this.feedback("cue", cue + ":" + key, linkId, false, cue);
23553
23889
  }
23554
- /** The newest time stored in each chat while this engine runs: a message landing well behind it is a catch-up. */
23890
+ /** The newest time written in each chat while this engine runs: a message written well before it is a catch-up. */
23555
23891
  newestAt = /* @__PURE__ */ new Map();
23556
- /** Only a new message at the end of its chat, or mine going out, says anything (engine/attention.ts). */
23892
+ /** The last place given in each chat while this engine runs (`arrivalKey`). */
23893
+ placedAt = /* @__PURE__ */ new Map();
23894
+ /** Only a new message, or mine going out, says anything (engine/attention.ts): judged by when it was written. */
23557
23895
  messageFeedback(type, message, newest = this.newestAt.get(message.linkId)) {
23558
- const attention = messageAttention(type, message, {
23896
+ const attention = messageAttention(type, {
23897
+ ...message,
23898
+ timestamp: shownTime(message)
23899
+ }, {
23559
23900
  startedAt: this.feedbackStartedAt,
23560
23901
  newest,
23561
23902
  now: Date.now()
@@ -24409,7 +24750,12 @@ var GhostlyNode = class GhostlyNode {
24409
24750
  },
24410
24751
  messages: (groupId) => db.getMessages(`group:${groupId}`),
24411
24752
  putMessage: async (message) => {
24412
- await db.putMessage(message);
24753
+ const kept = await db.getMessage(message.linkId, message.id);
24754
+ await db.putMessage(kept ? {
24755
+ ...message,
24756
+ timestamp: kept.timestamp,
24757
+ ...kept.sentAt !== void 0 && { sentAt: kept.sentAt }
24758
+ } : this.placed(message));
24413
24759
  await this.messagesChanged(message.linkId, [message.id]);
24414
24760
  this.emitState();
24415
24761
  }
@@ -24659,6 +25005,7 @@ var GhostlyNode = class GhostlyNode {
24659
25005
  });
24660
25006
  const history = /* @__PURE__ */ new Map();
24661
25007
  for (const stored of await db.getLinks()) {
25008
+ if (!stored.group) await this.settleHistory(stored.id);
24662
25009
  const messages = stored.group ? [] : await db.getMessages(stored.id);
24663
25010
  const storedFiles = stored.group ? [] : await fileStore.listForLink(stored.id);
24664
25011
  const waiting = new Set(messages.flatMap((m) => m.sender === "me" && m.delivery === "waiting" && m.file ? [m.file.id] : []));
@@ -24676,6 +25023,7 @@ var GhostlyNode = class GhostlyNode {
24676
25023
  if (stored.profile && !stored.group) await this.outboxFor(stored.id).recover();
24677
25024
  }
24678
25025
  this.transfersRestored = true;
25026
+ for (const group of await db.getGroups()) await this.settleHistory(`group:${group.id}`);
24679
25027
  await this.groups.load();
24680
25028
  for (const group of this.groups.views()) {
24681
25029
  const note = latestReaction(await db.getMessages(`group:${group.id}`));
@@ -24738,6 +25086,7 @@ var GhostlyNode = class GhostlyNode {
24738
25086
  async shutdown() {
24739
25087
  this.shuttingDown = true;
24740
25088
  this.depart();
25089
+ this.directPath.close();
24741
25090
  if (this.relayRetry) clearTimeout(this.relayRetry);
24742
25091
  if (this.activeSlotRetry) clearTimeout(this.activeSlotRetry);
24743
25092
  for (const timer of this.resumeTimers.values()) clearTimeout(timer);
@@ -24806,6 +25155,7 @@ var GhostlyNode = class GhostlyNode {
24806
25155
  ...this.transport.configure && { direct: true },
24807
25156
  ...this.transport.discovery && { discovery: this.transport.discovery() },
24808
25157
  ...typeof RTCPeerConnection === "undefined" && { webrtc: false },
25158
+ ...this.directPath.blocked && { directBlocked: true },
24809
25159
  ...typeof RTCPeerConnection === "undefined" && !Object.keys(this.nativeFactories).length && { groupLinks: false }
24810
25160
  },
24811
25161
  links: [...this.links.values()].filter((live) => !live.stored.group).map((live) => this.viewOf(live)).sort((a, b) => b.createdAt - a.createdAt),
@@ -25255,6 +25605,7 @@ var GhostlyNode = class GhostlyNode {
25255
25605
  groupHistoryGone(groupId) {
25256
25606
  const linkId = `group:${groupId}`;
25257
25607
  this.newestAt.delete(linkId);
25608
+ this.placedAt.delete(linkId);
25258
25609
  this.reactionNotes.delete(linkId);
25259
25610
  this.events.onMessages(linkId, []);
25260
25611
  }
@@ -25474,7 +25825,10 @@ var GhostlyNode = class GhostlyNode {
25474
25825
  * (`network`): relays left alone for failing on the old network are asked again at once.
25475
25826
  */
25476
25827
  wake(params = {}) {
25477
- if (params.network) this.transport.networkChanged?.();
25828
+ if (params.network) {
25829
+ this.transport.networkChanged?.();
25830
+ this.directPath.reset();
25831
+ }
25478
25832
  for (const live of this.links.values()) live.link?.wake();
25479
25833
  this.hold.wake();
25480
25834
  for (const network of WALLET_NETWORKS) {
@@ -25519,7 +25873,7 @@ var GhostlyNode = class GhostlyNode {
25519
25873
  };
25520
25874
  if (!live?.link) return { error: "You are offline" };
25521
25875
  if (!trimmed) return { error: null };
25522
- const timestamp = params.timestamp ?? Date.now();
25876
+ const timestamp = params.timestamp ?? arrivalKey(Date.now(), this.placedAt.get(linkId));
25523
25877
  if (live.stored.profile) live.link.setTyping(false);
25524
25878
  if (live.stored.profile) {
25525
25879
  const found = params.replyTo === void 0 ? void 0 : await this.replyFor(linkId, params.replyTo);
@@ -25590,6 +25944,7 @@ var GhostlyNode = class GhostlyNode {
25590
25944
  async sendChatText(live, trimmed, timestamp, preview, reply, forwarded, card) {
25591
25945
  const { link } = live, linkId = live.stored.id;
25592
25946
  if (!link) return { error: "You are offline" };
25947
+ this.chatInUse(live);
25593
25948
  const bytes = new TextEncoder().encode(trimmed).length;
25594
25949
  if (bytes > LIMITS.maxChatMessageBytes) return {
25595
25950
  error: `Message exceeds ${LIMITS.maxChatMessageBytes} UTF-8 bytes.`,
@@ -25841,7 +26196,7 @@ var GhostlyNode = class GhostlyNode {
25841
26196
  if (!takesPeerEdit(current, edit.m) || (current.edit?.seq ?? 0) >= edit.e) return null;
25842
26197
  const next = withEdit(current, {
25843
26198
  seq: edit.e,
25844
- at: receivedTimestamp(edit.ts),
26199
+ at: heardTime(edit.ts),
25845
26200
  text: edit.m,
25846
26201
  preview: edit.pv,
25847
26202
  card: edit.sc
@@ -26833,7 +27188,9 @@ var GhostlyNode = class GhostlyNode {
26833
27188
  }
26834
27189
  async createGroup({ name, profile }) {
26835
27190
  if (typeof name !== "string" || !name.trim()) throw new Error("Give the group a name");
26836
- return { groupId: await this.groups.create(name.trim().slice(0, 48), profile === "mesh" ? "mesh" : "community") };
27191
+ const clean = groupName(name);
27192
+ if (!clean) throw new Error(`A group's name is 1 to 64 characters on one line`);
27193
+ return { groupId: await this.groups.create(clean, profile === "mesh" ? "mesh" : "community") };
26837
27194
  }
26838
27195
  inviteToGroup({ groupId, linkId }) {
26839
27196
  if (!this.links.get(linkId)?.stored.profile || this.links.get(linkId)?.stored.group) throw new Error("Invite a paired contact");
@@ -27044,7 +27401,10 @@ var GhostlyNode = class GhostlyNode {
27044
27401
  if (typeof linkId !== "string" || !linkId) return { error: "No chat to react in" };
27045
27402
  if (linkId.startsWith("group:")) {
27046
27403
  const groupId = linkId.slice(6);
27047
- if (!this.membership(groupId)) return { error: "You are not in this group" };
27404
+ if (!this.membership(groupId)) return {
27405
+ error: this.groups.views().find((g) => g.id === groupId)?.statusReason ?? "You are not in this group",
27406
+ refused: true
27407
+ };
27048
27408
  const result = await this.reactions.mine(linkId, messageId, emoji);
27049
27409
  return "error" in result ? result : this.sendGroupReaction(groupId, result.reaction);
27050
27410
  }
@@ -28460,6 +28820,7 @@ var GhostlyNode = class GhostlyNode {
28460
28820
  const live = this.links.get(linkId);
28461
28821
  if (!live?.stored.group) return;
28462
28822
  this.links.delete(linkId);
28823
+ this.edgeRtcOff.delete(linkId);
28463
28824
  this.groupWakeReceived.delete(linkId);
28464
28825
  this.groupNativeWaiting.delete(linkId);
28465
28826
  this.groupNativeRetryAt = 0;
@@ -28601,7 +28962,7 @@ var GhostlyNode = class GhostlyNode {
28601
28962
  usdtPaymentsSupport: !entry,
28602
28963
  barkPaymentsSupport: !entry,
28603
28964
  params: stored,
28604
- rtcAvailable: typeof RTCPeerConnection !== "undefined",
28965
+ rtcAvailable: typeof RTCPeerConnection !== "undefined" && !this.edgeRtcOff.has(linkId),
28605
28966
  resume: !entry && stored.edgeLive ? resumeOn : void 0,
28606
28967
  native: {
28607
28968
  peerDescriptors: stored.peerDescriptors,
@@ -28693,6 +29054,10 @@ var GhostlyNode = class GhostlyNode {
28693
29054
  live.pairing = state;
28694
29055
  this.emitState();
28695
29056
  },
29057
+ onDirectEvidence: (evidence) => {
29058
+ this.directPath.note(peer, evidence);
29059
+ if (!entry && evidence !== "open" && evidence !== "closed") this.edgeWithoutRtc(linkId);
29060
+ },
28696
29061
  onDataLinkState: (state) => {
28697
29062
  traceJoin(group, `link.${state}`, { role });
28698
29063
  if (state === "open" || live.dataLink === "open") live.lastSyncAt = Date.now();
@@ -28723,7 +29088,27 @@ var GhostlyNode = class GhostlyNode {
28723
29088
  */
28724
29089
  keepsGroupNative(stored) {
28725
29090
  if (this.options.webrtcGroupLinks) return false;
28726
- return typeof RTCPeerConnection === "undefined" || !!stored.peerTransports && !stored.peerTransports.includes("webrtc/1");
29091
+ return typeof RTCPeerConnection === "undefined" || this.edgeRtcOff.has(stored.id) || !!stored.peerTransports && !stored.peerTransports.includes("webrtc/1");
29092
+ }
29093
+ /**
29094
+ * An edge's WebRTC attempt exchanged both descriptions and connected nothing, or found no candidate at all
29095
+ * (`DirectEvidence`): a network that lets no direct connection through (a VPN, a firewall, on either side). WebRTC is
29096
+ * all an edge between two apps that have it runs, and there is no TURN server unless someone set one, so the edge
29097
+ * never went live, and the member stayed unreachable for as long as that network lasted. The edge starts again as on an
29098
+ * app with no WebRTC (WISP 9xx § Transports): it runs its native endpoints and says so in its packet (`_tr`, with no
29099
+ * `webrtc/1`), the member's app starts its own for it as it does for a Linux Desktop, and the two meet over Iroh
29100
+ * through its relay. For this run of the app only.
29101
+ */
29102
+ async edgeWithoutRtc(linkId) {
29103
+ const live = this.links.get(linkId), link = live?.link;
29104
+ if (!live || !link || !live.stored.group || live.stored.groupEntry || this.shuttingDown || this.options.webrtcGroupLinks) return;
29105
+ if (this.edgeRtcOff.has(linkId) || this.keepsGroupNative(live.stored) || !Object.keys(this.nativeFactories).length) return;
29106
+ this.edgeRtcOff.add(linkId);
29107
+ traceLink(live.myPubKeyZ32, "edge-rtc-off", {});
29108
+ live.link = null;
29109
+ await link.stop(false).catch(() => {});
29110
+ if (this.links.get(linkId) !== live || live.link || this.shuttingDown || this.settings.online === false) return;
29111
+ this.startEdge(linkId);
28727
29112
  }
28728
29113
  startLink(linkId, messages) {
28729
29114
  const live = this.links.get(linkId);
@@ -28731,7 +29116,7 @@ var GhostlyNode = class GhostlyNode {
28731
29116
  const { stored } = live;
28732
29117
  if (stored.group) return this.startEdge(linkId);
28733
29118
  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);
29119
+ const lastSeenTimestamp = messages.reduce((max, m) => m.sender === "peer" && m.via === "pkarr" ? Math.max(max, m.sentAt ?? m.timestamp) : max, 0);
28735
29120
  const credentials = stored.profile && stored.participationSeed ? {
28736
29121
  seedB64: stored.participationSeed,
28737
29122
  peerKey: stored.pairedPeerKey,
@@ -28964,6 +29349,7 @@ var GhostlyNode = class GhostlyNode {
28964
29349
  live.peerAck = ack;
28965
29350
  this.emitState();
28966
29351
  },
29352
+ onDirectEvidence: (evidence) => this.directPath.note(stored.peerPubKeyZ32, evidence),
28967
29353
  onDataLinkState: (state) => {
28968
29354
  const was = live.dataLink;
28969
29355
  live.dataLink = state;
@@ -29023,6 +29409,7 @@ var GhostlyNode = class GhostlyNode {
29023
29409
  };
29024
29410
  db.patchLink(linkId, { inviteCode: void 0 });
29025
29411
  }
29412
+ if (message.via !== "datalink") this.chatInUse(live);
29026
29413
  return this.storeMessage({
29027
29414
  linkId,
29028
29415
  id: `peer_${message.id ?? message.timestamp}`,
@@ -29191,7 +29578,7 @@ var GhostlyNode = class GhostlyNode {
29191
29578
  get nativeFactories() {
29192
29579
  const relay = this.relaysHyperdht ? this.hyperdhtRelay : "";
29193
29580
  return {
29194
- ...relay ? { "hyperdht/1": async (seedB64) => (await import("./hyperdhtRelay-_rJsllcP.js")).createRelayedHyperEndpoint(seedB64, relay) } : {},
29581
+ ...relay ? { "hyperdht/1": async (seedB64) => (await import("./hyperdhtRelay-cMlRWrmD.js")).createRelayedHyperEndpoint(seedB64, relay) } : {},
29195
29582
  ...this.options.nativeTransports,
29196
29583
  ...this.options.irohWeb ? { "iroh/1": (seedB64) => createIrohWebEndpoint(seedB64, { relays: this.irohRelays }) } : {}
29197
29584
  };
@@ -29254,70 +29641,110 @@ var GhostlyNode = class GhostlyNode {
29254
29641
  }
29255
29642
  this.emitState();
29256
29643
  }
29257
- ensureNativeEndpoints(linkId) {
29644
+ /**
29645
+ * 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
29646
+ * lacks as the chat on screen would (a bot answering its ninth contact has no chat on screen).
29647
+ */
29648
+ chatInUse(live) {
29649
+ const link = live.link;
29650
+ if (live.stored.group || !link || link.isDataLinkOpen || !this.keepsNativeEndpoints(live.stored.id, live.stored)) return;
29651
+ if (Object.keys(this.nativeFactories).every((t) => link.availableTransports.includes(t))) return;
29652
+ this.ensureNativeEndpoints(live.stored.id, true);
29653
+ }
29654
+ /**
29655
+ * Whether the chat goes live only over a native transport: this app has no WebRTC, or its contact has none (its record
29656
+ * lists no `webrtc/1`). Not known yet (no record read) counts as only natively, as a chat before its first record must.
29657
+ */
29658
+ reachedOnlyNatively(live) {
29659
+ const theirs = live.stored.capsState?.peer?.transports ?? live.stored.peerTransports;
29660
+ return typeof RTCPeerConnection === "undefined" || !theirs || !theirs.includes("webrtc/1");
29661
+ }
29662
+ /** `inUse`: a text is going or coming in the chat now, which counts as the chat on screen does for taking a listener. */
29663
+ ensureNativeEndpoints(linkId, inUse = false) {
29258
29664
  const expected = this.links.get(linkId)?.link;
29259
- const operation = this.nativeQueue.then(async () => {
29665
+ const starts = Object.entries(this.nativeFactories).map(([transport, factory]) => {
29666
+ const key = transport;
29667
+ const operation = (this.nativeQueues.get(key) ?? Promise.resolve()).then(() => factory ? this.startNativeEndpoint(linkId, expected, key, factory, inUse) : false);
29668
+ this.nativeQueues.set(key, operation.then(() => {}, () => {}));
29669
+ return operation;
29670
+ });
29671
+ return Promise.all(starts).then((full) => {
29260
29672
  const live = this.links.get(linkId), link = live?.link;
29261
- if (this.shuttingDown || !live?.stored.profile || live.stored.deliveryMode === "dht" || !link || link !== expected) return;
29262
- const group = !!live.stored.group;
29263
- if (group && !this.keepsGroupNative(live.stored)) return;
29264
- let full = false;
29265
- for (const [transport, factory] of Object.entries(this.nativeFactories)) {
29266
- const key = transport;
29267
- if (!factory || link.availableTransports.includes(key)) continue;
29268
- live.transportErrors ??= {};
29269
- try {
29270
- const owners = [...this.links.values()].filter((other) => other.link?.availableTransports.includes(key));
29271
- if (group && (owners.length >= NATIVE_SLOTS || owners.filter((other) => other.stored.group).length >= 4)) {
29272
- full = true;
29273
- continue;
29274
- }
29275
- if (owners.length >= NATIVE_SLOTS) {
29276
- 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] ?? (this.activeLinkId === linkId ? owners.filter((other) => other !== live && other.stored.id !== this.activeLinkId && other.link?.canReleaseEndpoint(key)).sort((a, b) => a.lastMessageAt - b.lastMessageAt)[0] : void 0);
29277
- if (!victim) {
29278
- traceLink(live.myPubKeyZ32, "native-no-slot", {
29279
- transport: key,
29280
- active: this.activeLinkId === linkId
29281
- });
29282
- if (this.activeLinkId === linkId && !group) this.retryActiveSlot(linkId);
29283
- throw new Error("All eight native connection slots are in use. Disconnect a native connection in another chat, then reopen this chat or press Reconnect.");
29284
- }
29285
- await victim.link.releaseEndpoint(key);
29286
- victim.transportErrors ??= {};
29287
- victim.transportErrors[key] = "Listener released for another chat. Open this chat to restore it; your messages and transport identity are saved.";
29288
- if (victim.stored.group) this.groupNativeWaiting.add(victim.stored.id);
29289
- }
29290
- if (this.shuttingDown || live.link !== link) return;
29291
- const seed = live.stored.transportSeeds?.[key] ?? createIdentity().seedB64;
29292
- const transportSeeds = {
29293
- ...live.stored.transportSeeds,
29294
- [key]: seed
29295
- };
29296
- await db.patchLink(linkId, { transportSeeds });
29297
- live.stored = {
29298
- ...live.stored,
29299
- transportSeeds
29673
+ if (this.shuttingDown || !live || !link || link !== expected) return;
29674
+ if (live.stored.group && this.keepsGroupNative(live.stored)) {
29675
+ if (full.some(Boolean) && !link.availableTransports.some((t) => t !== "webrtc/1")) this.groupNativeWaiting.add(linkId);
29676
+ else this.groupNativeWaiting.delete(linkId);
29677
+ }
29678
+ this.emitState();
29679
+ });
29680
+ }
29681
+ /** One transport's listener for one chat, in that transport's queue. True when a group's link found no slot free. */
29682
+ async startNativeEndpoint(linkId, expected, key, factory, inUse) {
29683
+ const live = this.links.get(linkId), link = live?.link;
29684
+ if (this.shuttingDown || !live?.stored.profile || live.stored.deliveryMode === "dht" || !link || link !== expected) return false;
29685
+ const group = !!live.stored.group;
29686
+ if (group && !this.keepsGroupNative(live.stored)) return false;
29687
+ if (link.availableTransports.includes(key)) return false;
29688
+ live.transportErrors ??= {};
29689
+ try {
29690
+ const owners = [...this.links.values()].filter((other) => other.link?.availableTransports.includes(key));
29691
+ if (group && (owners.length >= NATIVE_SLOTS || owners.filter((other) => other.stored.group).length >= 4)) return true;
29692
+ if (owners.length >= NATIVE_SLOTS) {
29693
+ const now = Date.now();
29694
+ const taking = !group && (this.activeLinkId === linkId || inUse);
29695
+ 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);
29696
+ 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];
29697
+ const idleFor = idle ? now - idle.link.lastActivityAt : 0;
29698
+ if (idle && await idle.link.yieldEndpoint(key, 12e4)) {
29699
+ traceLink(live.myPubKeyZ32, "native-take", {
29700
+ transport: key,
29701
+ idle: idleFor,
29702
+ active: this.activeLinkId === linkId
29703
+ });
29704
+ live.nativeTakenAt = {
29705
+ ...live.nativeTakenAt,
29706
+ [key]: Date.now()
29300
29707
  };
29301
- const endpoint = await factory(seed);
29302
- if (this.shuttingDown || live.stored.deliveryMode === "dht" || live.link !== link || !this.links.has(linkId)) {
29303
- await endpoint.close();
29304
- return;
29305
- }
29306
- link.registerEndpoint(endpoint);
29307
- delete live.transportErrors[key];
29308
- this.capsChanged(linkId);
29309
- } catch (error) {
29310
- live.transportErrors[key] = error instanceof Error ? error.message : "Native adapter could not start. Reopen this chat to retry.";
29708
+ idle.transportErrors ??= {};
29709
+ 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.";
29710
+ } else if (!victim) {
29711
+ traceLink(live.myPubKeyZ32, "native-no-slot", {
29712
+ transport: key,
29713
+ active: this.activeLinkId === linkId
29714
+ });
29715
+ if (this.activeLinkId === linkId && !group) this.retryActiveSlot(linkId);
29716
+ 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.");
29717
+ } else {
29718
+ await victim.link.releaseEndpoint(key);
29719
+ victim.transportErrors ??= {};
29720
+ victim.transportErrors[key] = "Listener released for another chat. Open this chat to restore it; your messages and transport identity are saved.";
29721
+ if (victim.stored.group) this.groupNativeWaiting.add(victim.stored.id);
29311
29722
  }
29312
29723
  }
29313
- if (group) {
29314
- if (full && !link.availableTransports.some((t) => t !== "webrtc/1")) this.groupNativeWaiting.add(linkId);
29315
- else this.groupNativeWaiting.delete(linkId);
29724
+ if (this.shuttingDown || live.link !== link) return false;
29725
+ const seed = live.stored.transportSeeds?.[key] ?? createIdentity().seedB64;
29726
+ const transportSeeds = {
29727
+ ...live.stored.transportSeeds,
29728
+ [key]: seed
29729
+ };
29730
+ live.stored = {
29731
+ ...live.stored,
29732
+ transportSeeds
29733
+ };
29734
+ await db.patchLink(linkId, { transportSeeds });
29735
+ const endpoint = await factory(seed);
29736
+ if (this.shuttingDown || live.stored.deliveryMode === "dht" || live.link !== link || !this.links.has(linkId)) {
29737
+ await endpoint.close();
29738
+ return false;
29316
29739
  }
29740
+ link.registerEndpoint(endpoint);
29741
+ delete live.transportErrors[key];
29742
+ this.capsChanged(linkId);
29317
29743
  this.emitState();
29318
- });
29319
- this.nativeQueue = operation.catch(() => {});
29320
- return operation;
29744
+ } catch (error) {
29745
+ live.transportErrors[key] = error instanceof Error ? error.message : "Native adapter could not start. Reopen this chat to retry.";
29746
+ }
29747
+ return false;
29321
29748
  }
29322
29749
  /**
29323
29750
  * The order saved chats start in, which is the order they take native listeners in (`NATIVE_SLOTS` per transport):
@@ -29446,15 +29873,46 @@ var GhostlyNode = class GhostlyNode {
29446
29873
  deleted: unique.filter((_, i) => !rows[i])
29447
29874
  });
29448
29875
  }
29876
+ /**
29877
+ * At the start, before anything reads a history: received rows an earlier version stored under a time that has not
29878
+ * come yet take a place before now (`settleAhead`). One row is read when nothing is ahead, which is every start but
29879
+ * the first after the update.
29880
+ */
29881
+ async settleHistory(linkId) {
29882
+ const now = Date.now();
29883
+ const newest = (await db.getMessagePage(linkId, { limit: 1 })).messages[0];
29884
+ if (!newest || newest.timestamp <= now) return;
29885
+ for (const row of settleAhead(await db.getMessages(linkId), now)) await db.putMessage(row);
29886
+ }
29887
+ /**
29888
+ * A new row with its place in its history. A received row takes it here and now, past the last place given in its
29889
+ * chat: the time its sender says is kept beside it (`sentAt`) and shown, and orders nothing (WISP 400, requirement
29890
+ * 10). A sender's clock ahead or behind then moves no message past another, and what I send next goes below what I
29891
+ * just received. A row of mine keeps its own time; one placed already (it has `sentAt`) is left as it is. A line
29892
+ * of a group's history (a membership change, a rename) is not a message: it stays at its commit's time, as before.
29893
+ */
29894
+ placed(message) {
29895
+ const now = Date.now(), last = this.placedAt.get(message.linkId) ?? 0;
29896
+ if (message.event) return message.sender === "peer" ? {
29897
+ ...message,
29898
+ timestamp: receivedTimestamp(message.timestamp)
29899
+ } : message;
29900
+ if (message.sender === "peer" && message.sentAt === void 0) {
29901
+ const sentAt = claimedTime(message.timestamp);
29902
+ message = {
29903
+ ...message,
29904
+ timestamp: arrivalKey(now, last),
29905
+ ...sentAt !== void 0 && { sentAt }
29906
+ };
29907
+ this.placedAt.set(message.linkId, message.timestamp);
29908
+ } else this.placedAt.set(message.linkId, Math.max(last, Math.min(message.timestamp, now)));
29909
+ return message;
29910
+ }
29449
29911
  async storeMessage(message) {
29450
29912
  await this.storeNewMessage(message);
29451
29913
  }
29452
29914
  /** Stores a message; false when it was there already (or deleted here): nothing new came. */
29453
29915
  async storeNewMessage(message) {
29454
- if (message.sender === "peer") message = {
29455
- ...message,
29456
- timestamp: receivedTimestamp(message.timestamp)
29457
- };
29458
29916
  const edge = this.links.get(message.linkId)?.stored;
29459
29917
  if (edge?.group && edge.groupPeer && !edge.groupEntry) message = {
29460
29918
  ...message,
@@ -29469,6 +29927,7 @@ var GhostlyNode = class GhostlyNode {
29469
29927
  id: `${message.linkId}:${message.id}`,
29470
29928
  ...message.sender === "peer" ? { member: pay.member } : {}
29471
29929
  };
29930
+ message = this.placed(message);
29472
29931
  const live = this.links.get(message.linkId);
29473
29932
  if (live?.stored.deletedIds?.includes(message.id)) return false;
29474
29933
  message = await this.resolveReply(message);
@@ -29498,7 +29957,7 @@ var GhostlyNode = class GhostlyNode {
29498
29957
  }
29499
29958
  if (!await db.addMessage(message)) return false;
29500
29959
  const newest = this.newestAt.get(message.linkId);
29501
- if (!message.event) this.newestAt.set(message.linkId, Math.max(newest ?? 0, message.timestamp));
29960
+ if (!message.event) this.newestAt.set(message.linkId, Math.max(newest ?? 0, shownTime(message)));
29502
29961
  if (message.sender === "peer") this.messageFeedback("message", message, newest);
29503
29962
  if (live) live.lastMessageAt = Math.max(live.lastMessageAt, message.timestamp);
29504
29963
  const early = message.sender === "peer" && message.id.startsWith("peer_") ? this.editBuffer.take(message.linkId, message.id.slice(5)) : void 0;
@@ -29804,4 +30263,4 @@ function signalKind(signal) {
29804
30263
  //#endregion
29805
30264
  export { EngineServer, hyperdhtRelayProblem as n, server_exports as t };
29806
30265
 
29807
- //# sourceMappingURL=server-C-8wbsAX.js.map
30266
+ //# sourceMappingURL=server-Cajp8VLY.js.map