@glyphteck/veyl 0.73.0 → 0.74.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/account.js CHANGED
@@ -16818,6 +16818,7 @@ var WALLET_PENDING_TRANSFER_STALE_RETRY_MS = 2 * MINUTE_MS;
16818
16818
  var WALLET_PENDING_TRANSFER_STUCK_RETRY_MS = 10 * MINUTE_MS;
16819
16819
  var WALLET_PENDING_TRANSFER_DORMANT_RETRY_MS = HOUR_MS;
16820
16820
  var WALLET_TRANSFER_CACHE_WRITE_DELAY_MS = 3 * MS_PER_SECOND;
16821
+ var BITCOIN_FEES_MAX_AGE_MS = 5 * 60000;
16821
16822
 
16822
16823
  // ../../core/calls/wire.js
16823
16824
  var CALL_REQUEST_MAX_BYTES = 192 * 1024;
@@ -17672,7 +17673,8 @@ var domains = Object.freeze({
17672
17673
  veyl: `veyl.${ROOT_DOMAIN}`,
17673
17674
  veylDev: `dev.veyl.${ROOT_DOMAIN}`,
17674
17675
  live: `live.veyl.${ROOT_DOMAIN}`,
17675
- liveDev: `live.dev.veyl.${ROOT_DOMAIN}`
17676
+ liveDev: `live.dev.veyl.${ROOT_DOMAIN}`,
17677
+ bitcoin: `bitcoin.veyl.${ROOT_DOMAIN}`
17676
17678
  });
17677
17679
  function getVeylDevWebOrigin(port) {
17678
17680
  const value = Number(port);
@@ -17693,6 +17695,7 @@ var liveEndpoints = Object.freeze({
17693
17695
  prod: `wss://${domains.live}`,
17694
17696
  dev: `wss://${domains.liveDev}`
17695
17697
  });
17698
+ var bitcoinEndpoint = `https://${domains.bitcoin}/current`;
17696
17699
  var appDomains = Object.freeze([
17697
17700
  domains.veyl
17698
17701
  ]);
@@ -17726,9 +17729,11 @@ function isAddressOnNetwork(address, network) {
17726
17729
 
17727
17730
  // ../../core/settings.js
17728
17731
  var SEND_ON_SCAN_ENABLED = false;
17732
+ var WEB_LAYOUTS = ["floating", "sidebar"];
17729
17733
  var WALLET_NETWORKS = new Set([MAINNET_NETWORK, REGTEST_NETWORK]);
17730
17734
  var defaultSettings = {
17731
17735
  glass: true,
17736
+ webLayout: "floating",
17732
17737
  moneyFormat: "usd",
17733
17738
  ghostWallet: true,
17734
17739
  showChatPreviews: true,
@@ -17736,6 +17741,8 @@ var defaultSettings = {
17736
17741
  confirmSend: false,
17737
17742
  faceID: null,
17738
17743
  walletNetwork: null,
17744
+ peerAudio: {},
17745
+ callAudio: { muted: false, deafened: false },
17739
17746
  autolock: {
17740
17747
  timer: "never",
17741
17748
  onHide: false,
@@ -17797,9 +17804,14 @@ function normalizeSettings(settings, base = defaultSettings) {
17797
17804
  };
17798
17805
  next.autolock = normalizeAutolock(settings.autolock, current.autolock);
17799
17806
  next.walletNetwork = normalizeWalletNetworkSetting(next.walletNetwork);
17807
+ next.peerAudio = normalizePeerAudio(settings.peerAudio, current.peerAudio);
17808
+ next.callAudio = normalizeCallAudio(settings.callAudio, current.callAudio);
17800
17809
  if (!MONEY_FORMATS.includes(next.moneyFormat)) {
17801
17810
  throw new Error("bad moneyFormat");
17802
17811
  }
17812
+ if (!WEB_LAYOUTS.includes(next.webLayout)) {
17813
+ throw new Error("bad webLayout");
17814
+ }
17803
17815
  if (typeof next.glass !== "boolean") {
17804
17816
  throw new Error("glass must be boolean");
17805
17817
  }
@@ -17823,6 +17835,32 @@ function normalizeSettings(settings, base = defaultSettings) {
17823
17835
  }
17824
17836
  return next;
17825
17837
  }
17838
+ function normalizeCallAudio(patch, base = defaultSettings.callAudio) {
17839
+ if (patch !== undefined && (!patch || typeof patch !== "object" || Array.isArray(patch)))
17840
+ throw new Error("bad call audio settings");
17841
+ const { muted, deafened } = { ...defaultSettings.callAudio, ...base, ...patch };
17842
+ if (typeof muted !== "boolean" || typeof deafened !== "boolean")
17843
+ throw new Error("bad call audio settings");
17844
+ return { muted, deafened };
17845
+ }
17846
+ function normalizePeerAudio(patch, base = {}) {
17847
+ if (patch === undefined)
17848
+ return base;
17849
+ if (!patch || typeof patch !== "object" || Array.isArray(patch))
17850
+ throw new Error("bad peer audio settings");
17851
+ const entries = new Map(Object.entries(base));
17852
+ for (const [chatPK, audio] of Object.entries(patch)) {
17853
+ if (!/^[a-f0-9]{64}$/u.test(chatPK) || !audio || typeof audio !== "object" || Array.isArray(audio)) {
17854
+ throw new Error("bad peer audio settings");
17855
+ }
17856
+ const { volume = 100, muted = false } = { ...entries.get(chatPK), ...audio };
17857
+ if (!Number.isFinite(volume) || volume < 1 || volume > 200 || typeof muted !== "boolean") {
17858
+ throw new Error("bad peer audio settings");
17859
+ }
17860
+ entries.set(chatPK, { volume, muted });
17861
+ }
17862
+ return Object.fromEntries(entries);
17863
+ }
17826
17864
 
17827
17865
  // ../../node_modules/.bun/@noble+ciphers@2.4.0/node_modules/@noble/ciphers/_arx.js
17828
17866
  var encodeStr = (str) => Uint8Array.from(str.split(""), (c) => c.charCodeAt(0));
@@ -18716,6 +18754,9 @@ function normalizeBitcoinPaymentFeeQuote(feeQuote) {
18716
18754
  };
18717
18755
  }
18718
18756
  function getFeeRateSatsPerVbyte(bitcoin, speed = DEFAULT_FEE_RATE_SPEED) {
18757
+ const observedAt = Date.parse(bitcoin?.fees?.updatedAtIso);
18758
+ if (!Number.isFinite(observedAt) || Date.now() - observedAt > BITCOIN_FEES_MAX_AGE_MS || observedAt > Date.now() + 60000)
18759
+ return null;
18719
18760
  const rates = bitcoin?.fees?.satPerVbyte;
18720
18761
  const key = String(speed || DEFAULT_FEE_RATE_SPEED);
18721
18762
  const keys = FEE_RATE_FALLBACKS[key] ?? [key, ...FEE_RATE_FALLBACKS.default];
@@ -19746,6 +19787,9 @@ function readAccountType(profile) {
19746
19787
  function hasPeerKeys(profile) {
19747
19788
  return !!(profile?.walletPK || profile?.chatPK);
19748
19789
  }
19790
+ function hasChatIdentity(profile) {
19791
+ return !!(profile?.uid && profile?.chatPK && profile?.chatSigningPK && profile?.notificationPK);
19792
+ }
19749
19793
  function readProfileChatIdentity(profile) {
19750
19794
  const chat = profile?.identities?.chat;
19751
19795
  return {
@@ -19760,7 +19804,7 @@ function peerUid(peer) {
19760
19804
  return typeof peer === "string" ? cleanText(peer) : cleanText(peer?.uid);
19761
19805
  }
19762
19806
  function isFullProfile(profile) {
19763
- return !!(profile?.uid && (("username" in profile) || ("walletPK" in profile) && ("chatPK" in profile)));
19807
+ return !!(profile?.uid && hasPeerKeys(profile) && ["username", "walletPK", "chatPK", "chatSigningPK", "notificationPK"].every((key) => (key in profile)) && (!profile.chatPK || hasChatIdentity(profile)));
19764
19808
  }
19765
19809
  function normalizeProfile(profile, uid = profile?.uid || null) {
19766
19810
  return {
@@ -19861,6 +19905,8 @@ function createUser({ cloud, network, avatarCache = null, diag = null, onSession
19861
19905
  let authSession = 0;
19862
19906
  let userUid = null;
19863
19907
  let settingsKey = null;
19908
+ let settingsWriter = null;
19909
+ let openedSettingsBody;
19864
19910
  let agreementStored = null;
19865
19911
  let agreementOverride = null;
19866
19912
  let agreementAcceptance = null;
@@ -20249,6 +20295,7 @@ function createUser({ cloud, network, avatarCache = null, diag = null, onSession
20249
20295
  settingsBody: privateData.settings ?? null,
20250
20296
  settingsFromCache: false
20251
20297
  }));
20298
+ refreshUnlockedSettings();
20252
20299
  }), current((error) => {
20253
20300
  markError(diag, "user.settings.snapshot", authStartedAt, error);
20254
20301
  if (revokeAuthenticationError(authUser, error, "settings-listener"))
@@ -20464,10 +20511,12 @@ function createUser({ cloud, network, avatarCache = null, diag = null, onSession
20464
20511
  throw new Error("settings key required");
20465
20512
  if (state.settingsBody === undefined)
20466
20513
  throw new Error("settings not available");
20467
- const nextSettings = state.settingsBody === null ? settingsState() : await openSettings(key, uid, state.settingsBody);
20514
+ const body = state.settingsBody;
20515
+ const nextSettings = body === null ? settingsState() : await openSettings(key, uid, body);
20468
20516
  if (!isCurrentUser(uid, session))
20469
20517
  return nextSettings;
20470
20518
  setSettingsKey(key);
20519
+ openedSettingsBody = body;
20471
20520
  setState((user) => ({
20472
20521
  ...user,
20473
20522
  settings: settingsState(nextSettings)
@@ -20488,15 +20537,59 @@ function createUser({ cloud, network, avatarCache = null, diag = null, onSession
20488
20537
  throw new Error("auth");
20489
20538
  if (!settingsKey)
20490
20539
  throw new Error("settings locked");
20491
- const nextSettings = await cloud.user.settings.write(uid, patch, { currentSettings: state.settings, key: settingsKey });
20492
- if (!isCurrentUser(uid, session))
20493
- return nextSettings;
20494
- setState((user) => ({
20495
- ...user,
20496
- settingsReady: true,
20497
- settings: settingsState(nextSettings)
20498
- }));
20499
- return nextSettings;
20540
+ normalizeSettings(patch, state.settings);
20541
+ if (!settingsWriter || settingsWriter.key !== settingsKey) {
20542
+ settingsWriter = { key: settingsKey, uid, session, pending: [], running: false };
20543
+ }
20544
+ const writer = settingsWriter;
20545
+ const result = new Promise((resolve, reject) => writer.pending.push({ patch, resolve, reject }));
20546
+ writeSettings(writer);
20547
+ return result;
20548
+ }
20549
+ async function writeSettings(writer) {
20550
+ if (writer.running)
20551
+ return;
20552
+ writer.running = true;
20553
+ const current = () => settingsKey === writer.key && isCurrentUser(writer.uid, writer.session);
20554
+ while (writer.pending.length) {
20555
+ const batch = writer.pending.splice(0);
20556
+ try {
20557
+ if (!current())
20558
+ throw new Error("settings locked");
20559
+ const desired = batch.reduce((settings2, { patch }) => normalizeSettings(patch, settings2), state.settings);
20560
+ const { settings, body } = await cloud.user.settings.write(writer.uid, desired, { currentSettings: state.settings, key: writer.key });
20561
+ if (!current())
20562
+ throw new Error("settings locked");
20563
+ openedSettingsBody = body;
20564
+ setState((user) => ({ ...user, settingsReady: true, settings: settingsState(settings), settingsBody: body }));
20565
+ for (const request of batch)
20566
+ request.resolve(settings);
20567
+ } catch (error) {
20568
+ for (const request of batch)
20569
+ request.reject(error);
20570
+ }
20571
+ }
20572
+ writer.running = false;
20573
+ if (settingsWriter === writer)
20574
+ settingsWriter = null;
20575
+ refreshUnlockedSettings();
20576
+ }
20577
+ async function refreshUnlockedSettings() {
20578
+ const key = settingsKey;
20579
+ const body = state.settingsBody;
20580
+ if (!key || body === undefined || body === openedSettingsBody || settingsWriter?.key === key)
20581
+ return;
20582
+ const uid = state.uid;
20583
+ try {
20584
+ const settings = body === null ? settingsState() : await openSettings(key, uid, body);
20585
+ if (settingsKey !== key || state.settingsBody !== body || settingsWriter?.key === key)
20586
+ return;
20587
+ openedSettingsBody = body;
20588
+ setState((user) => ({ ...user, settings: settingsState(settings) }));
20589
+ } catch (error) {
20590
+ if (settingsKey === key && state.settingsBody === body)
20591
+ markError(diag, "user.settings.open", Date.now(), error);
20592
+ }
20500
20593
  }
20501
20594
  function isBlocked(peer) {
20502
20595
  const nextPeerUid = peerUid(peer);
@@ -25059,7 +25152,7 @@ function groupEvidence(messages, byKey, memberStatesByEpoch) {
25059
25152
  const target = byKey.get(reference);
25060
25153
  addEvidence(evidence, messageEpochId(target), actor, messageOrderMs(target), seenAt);
25061
25154
  }
25062
- if (canShowMsg(message) && !isSystemMsg(message)) {
25155
+ if (canShowMsg(message)) {
25063
25156
  addEvidence(evidence, messageEpochId(message), actor, seenAt, seenAt);
25064
25157
  }
25065
25158
  }
@@ -25087,11 +25180,12 @@ function analyzeMessageExpiry(messages, selfChatPublicKey, _peerChatPublicKey, o
25087
25180
  const shorten = [];
25088
25181
  let nextExpiryAt = null;
25089
25182
  const latestStart = latestCallStartKey(projectedMessages.filter(isServerConfirmedMsg));
25183
+ const endedCalls = new Set(projectedMessages.filter(isServerConfirmedMsg).filter((message) => readCallMessage(message)?.event === "ended").map((message) => message.callId));
25090
25184
  for (const message of projectedMessages) {
25091
25185
  if (!isServerConfirmedMsg(message) || !canShowMsg(message) || message.ttl == null)
25092
25186
  continue;
25093
25187
  const call = readCallMessage(message);
25094
- if (call?.event === "started" && getMessageKey(message) === latestStart && (options.activeCallId === undefined || options.activeCallId === call.callId))
25188
+ if (call?.event === "started" && getMessageKey(message) === latestStart && (options.activeCallId === undefined && !endedCalls.has(call.callId) || options.activeCallId === call.callId))
25095
25189
  continue;
25096
25190
  const messageMs = messageOrderMs(message);
25097
25191
  const epochId = messageEpochId(message);
@@ -26446,7 +26540,7 @@ function partitionExpiredMessageRecords(records, expiredKeys, cache, now = Date.
26446
26540
  return { activeRecords, expiredRecords };
26447
26541
  }
26448
26542
  function canHideExpiredMessage(message, chatPK) {
26449
- return isServerConfirmedMsg(message) && isPeerMsg(message, chatPK) && canShowMsg(message) && !isControlMsg(message) && !isSystemMsg(message) && message.ttl != null && message.permanent !== true;
26543
+ return isServerConfirmedMsg(message) && isPeerMsg(message, chatPK) && canShowMsg(message) && message.ttl != null && message.permanent !== true;
26450
26544
  }
26451
26545
  function hiddenMessageKeys(messages, chatPK, peerChatPK, options = {}) {
26452
26546
  if (!chatPK || !peerChatPK || !Array.isArray(messages) || !messages.length)
@@ -40686,27 +40780,23 @@ function createChatSessionLaunches({
40686
40780
  const openDirectChat = async (profile) => {
40687
40781
  const operation = operationContext();
40688
40782
  const { chatPK, blocked } = operation.identity;
40689
- const member = profileMember(profile);
40690
- if (member.chatPK === chatPK)
40783
+ if (!profile?.uid || !profile?.chatPK)
40784
+ throw new Error("peer identity required");
40785
+ if (profile.chatPK === chatPK)
40691
40786
  return openNotesChat(operation);
40692
- if (new Set(blocked).has(member.uid))
40787
+ if (new Set(blocked).has(profile.uid))
40693
40788
  throw new Error("blocked peer cannot be opened");
40694
40789
  assertDirectAdmission(profile, operation);
40695
- const existingChatId = getPeerChatId(member.chatPK, operation);
40790
+ const existingChatId = getPeerChatId(profile.chatPK, operation);
40696
40791
  if (existingChatId) {
40697
40792
  setSelectedChat(existingChatId);
40698
40793
  return existingChatId;
40699
40794
  }
40795
+ const member = { uid: profile.uid, chatPK: profile.chatPK };
40700
40796
  const routeId = directRouteIdForPeer(member.chatPK, operation);
40701
40797
  if (operation.pendingOwner.getPendingLaunch()?.id === routeId)
40702
40798
  return routeId;
40703
- operation.pendingOwner.showEmpty(routeId, [{
40704
- ...member,
40705
- username: profile?.username || null,
40706
- avatar: profile?.avatar || null,
40707
- accountType: profile?.accountType || null,
40708
- chatAdmission: normalizeChatAdmission(profile?.chatAdmission)
40709
- }], [member], "direct");
40799
+ operation.pendingOwner.showEmpty(routeId, [profile], [member], "direct");
40710
40800
  const materialization = directMaterializations.get(routeId);
40711
40801
  if (materialization && isCurrent(materialization.operation)) {
40712
40802
  materialization.promise.then((chatId) => {
@@ -46189,6 +46279,11 @@ function openCallMailbox({ cloud, capability, endpoint, protocol, onChange, onEr
46189
46279
  }
46190
46280
  return Object.freeze({
46191
46281
  getSnapshot: () => candidate || record,
46282
+ async ready() {
46283
+ const response = await channel.ready();
46284
+ await consume(response.record);
46285
+ return record;
46286
+ },
46192
46287
  async read() {
46193
46288
  const response = await channel.request({ type: "read", cursor, headDigest: record?.headDigest ?? null });
46194
46289
  await consume(response.record);
@@ -46286,6 +46381,7 @@ function openAccountCallLease({ cloud, capability: accountCapability, endpoint,
46286
46381
  let expiry = null;
46287
46382
  let generation = 0;
46288
46383
  let acquiring = false;
46384
+ let rotating = Promise.resolve();
46289
46385
  const attempts = new Set;
46290
46386
  const waiters = new Set;
46291
46387
  const current = () => !closed;
@@ -46386,6 +46482,7 @@ function openAccountCallLease({ cloud, capability: accountCapability, endpoint,
46386
46482
  async function grant(command) {
46387
46483
  if (closed)
46388
46484
  throw new Error("call lease closed");
46485
+ const requestedAt = clock();
46389
46486
  const attempt = createCallLeaseAttempt(command, clock);
46390
46487
  const started = generation;
46391
46488
  attempts.add(attempt);
@@ -46405,6 +46502,8 @@ function openAccountCallLease({ cloud, capability: accountCapability, endpoint,
46405
46502
  onLost?.();
46406
46503
  }, Math.max(0, deadline.wallDeadline - Date.now()));
46407
46504
  clearTimeout(renewal);
46505
+ const now = clock();
46506
+ const elapsed = Math.max(now.wall - requestedAt.wall, now.monotonic - requestedAt.monotonic);
46408
46507
  renewal = setTimeout(() => {
46409
46508
  if (!owned)
46410
46509
  return;
@@ -46416,14 +46515,35 @@ function openAccountCallLease({ cloud, capability: accountCapability, endpoint,
46416
46515
  onLost?.();
46417
46516
  onError?.(error);
46418
46517
  });
46419
- }, 5000);
46518
+ }, Math.max(0, 5000 - elapsed));
46420
46519
  return value;
46421
46520
  } finally {
46422
46521
  attempts.delete(attempt);
46423
46522
  attempt.cancel();
46424
46523
  }
46425
46524
  }
46525
+ async function rotateMedia() {
46526
+ if (closed || acquiring || !owned || !renewalRemaining(generation))
46527
+ throw new Error("call ownership unavailable");
46528
+ const previous = stop();
46529
+ const started = generation;
46530
+ await request({ type: "release", holder: endpoint.holder, revision: previous.revision });
46531
+ if (closed || started !== generation)
46532
+ throw new Error("call transfer cancelled");
46533
+ return grant({
46534
+ type: "claim",
46535
+ holder: endpoint.holder,
46536
+ expectedRevision: previous.revision,
46537
+ payload: previous.payload,
46538
+ takeover: false
46539
+ });
46540
+ }
46426
46541
  return Object.freeze({
46542
+ rotateMedia() {
46543
+ const next = rotating.then(rotateMedia);
46544
+ rotating = next.catch(() => {});
46545
+ return next;
46546
+ },
46427
46547
  async acquire(descriptor) {
46428
46548
  if (closed)
46429
46549
  throw new Error("call lease closed");
@@ -46432,7 +46552,7 @@ function openAccountCallLease({ cloud, capability: accountCapability, endpoint,
46432
46552
  acquiring = true;
46433
46553
  const started = generation;
46434
46554
  try {
46435
- await request({ type: "read" });
46555
+ await accept((await channel.ready()).record);
46436
46556
  const payload = await capability.seal(descriptor);
46437
46557
  if (closed || started !== generation)
46438
46558
  throw new Error("call transfer cancelled");
@@ -46872,7 +46992,7 @@ function openCallRoom({
46872
46992
  pulseTimer = setTimeout(pulse, 1e4);
46873
46993
  }
46874
46994
  async function beginStart(create) {
46875
- const record = await mailbox.read();
46995
+ const record = await mailbox.ready();
46876
46996
  if (closed || departing)
46877
46997
  throw cancelled2();
46878
46998
  if (!create && !record?.head)
@@ -47026,7 +47146,7 @@ function createCallActivity({ holder, onSpeaking, now = Date.now, setTimer = set
47026
47146
  return;
47027
47147
  const admitted = new Map(peers.map((peer) => [peer.holder, peer]));
47028
47148
  local = admitted.has(holder) && !muted && !deafened;
47029
- direct = mode === "direct" && directPeer !== holder && admitted.has(directPeer) && !admitted.get(directPeer).muted ? directPeer : null;
47149
+ direct = mode === "direct" && directPeer !== holder && admitted.has(directPeer) ? directPeer : null;
47030
47150
  const nextSources = new Map(local ? [[holder, "local"]] : []);
47031
47151
  const nextRoutes2 = new Map;
47032
47152
  const ambiguous = new Set;
@@ -47034,7 +47154,7 @@ function createCallActivity({ holder, onSpeaking, now = Date.now, setTimer = set
47034
47154
  nextSources.set(direct, "direct");
47035
47155
  if (mode === "group") {
47036
47156
  for (const [id, mid] of received) {
47037
- if (id === holder || !admitted.has(id) || admitted.get(id).muted || typeof mid !== "string" || !mid)
47157
+ if (id === holder || !admitted.has(id) || typeof mid !== "string" || !mid)
47038
47158
  continue;
47039
47159
  if (nextRoutes2.has(mid)) {
47040
47160
  nextSources.delete(nextRoutes2.get(mid));
@@ -47101,13 +47221,14 @@ var validMid = (value) => typeof value === "string" && /^[A-Za-z0-9_-]{1,32}$/u.
47101
47221
  var MAX_PENDING_SIGNALS = 128;
47102
47222
  var CONNECTION_TIMEOUT_MS = 15000;
47103
47223
  var retiredConnection = new Error("call connection retired");
47104
- function createCallMediaSession({ port, mode, holder, provider, signal, publish, onState, onError, onExpired, onSpeaking }) {
47224
+ function createCallMediaSession({ port, mode, holder, preparation, provider, signal, publish, onState, onError, onExpired, onSpeaking, diag }) {
47105
47225
  let media = null;
47106
47226
  let closed = false;
47107
47227
  let deadline = null;
47108
47228
  let keys = null;
47109
47229
  let peers = [];
47110
47230
  let sessionId = null;
47231
+ let published = false;
47111
47232
  let started = false;
47112
47233
  let directPeer = null;
47113
47234
  let offered = false;
@@ -47230,7 +47351,11 @@ function createCallMediaSession({ port, mode, holder, provider, signal, publish,
47230
47351
  for (const id of pendingPeerAudio.keys())
47231
47352
  settlePeerAudio(id, new Error("call media closed"));
47232
47353
  pendingSignals.length = incomingSignals.length = 0;
47233
- const operations = [disposeConnection(previous), retiringConnection];
47354
+ const operations = [
47355
+ disposeConnection(previous),
47356
+ retiringConnection,
47357
+ openingConnection?.then((opened) => opened !== previous ? disposeConnection(opened) : undefined, () => {})
47358
+ ];
47234
47359
  closing = Promise.allSettled(operations).then((results) => {
47235
47360
  const failed = results.find((result) => result.status === "rejected");
47236
47361
  if (failed)
@@ -47243,22 +47368,24 @@ function createCallMediaSession({ port, mode, holder, provider, signal, publish,
47243
47368
  const live = () => !closed && generation === connectionGeneration;
47244
47369
  await retiringConnection;
47245
47370
  current(generation);
47246
- if (!iceServers) {
47247
- const configuration = await provider("turn", {});
47248
- current(generation);
47249
- iceServers = configuration.iceServers;
47250
- }
47371
+ const startedAt = Date.now();
47372
+ const prepared = preparation;
47373
+ preparation = null;
47251
47374
  const opening = port.open({
47252
47375
  id: holder,
47253
47376
  mode,
47254
- iceServers,
47377
+ preparation: prepared,
47255
47378
  onState: (value) => {
47256
47379
  if (!live())
47257
47380
  return;
47258
47381
  connectionState = value.state;
47259
- if (connectionState === "connected")
47382
+ if (connectionState === "connected") {
47260
47383
  settleConnection();
47261
- else if (["failed", "closed"].includes(connectionState))
47384
+ serialize(async () => {
47385
+ if (live())
47386
+ await media?.startSource?.();
47387
+ });
47388
+ } else if (["failed", "closed"].includes(connectionState))
47262
47389
  settleConnection(new Error("call connection failed"));
47263
47390
  onState?.(value);
47264
47391
  },
@@ -47295,34 +47422,51 @@ function createCallMediaSession({ port, mode, holder, provider, signal, publish,
47295
47422
  }
47296
47423
  });
47297
47424
  openingConnection = opening;
47298
- let opened;
47425
+ const preparing = opening.then(async (opened) => {
47426
+ current(generation);
47427
+ media = opened;
47428
+ await opened.mute(muted);
47429
+ current(generation);
47430
+ await opened.deafen(deafened);
47431
+ current(generation);
47432
+ if (microphoneVolume !== 1) {
47433
+ await opened.setMicrophoneVolume(microphoneVolume);
47434
+ current(generation);
47435
+ }
47436
+ await opened.prepare();
47437
+ current(generation);
47438
+ markDone(diag, "call.media.setup", startedAt, { stage: "local" });
47439
+ return opened;
47440
+ });
47299
47441
  try {
47300
- opened = await opening;
47301
- } finally {
47302
- if (openingConnection === opening)
47303
- openingConnection = null;
47304
- }
47305
- if (!live()) {
47306
- await disposeConnection(opened);
47442
+ const [opened, configuration] = await Promise.all([
47443
+ preparing,
47444
+ Promise.resolve().then(() => iceServers ? { iceServers, sessionId } : provider(mode === "group" ? "session/new" : "turn", {})).then((value) => {
47445
+ current(generation);
47446
+ markDone(diag, "call.media.setup", startedAt, { stage: "provider" });
47447
+ return value;
47448
+ })
47449
+ ]);
47307
47450
  current(generation);
47308
- }
47309
- media = opened;
47310
- await opened.mute(muted);
47311
- current(generation);
47312
- await opened.deafen(deafened);
47313
- current(generation);
47314
- if (microphoneVolume !== 1) {
47315
- await opened.setMicrophoneVolume(microphoneVolume);
47451
+ iceServers = configuration.iceServers;
47452
+ if (mode === "group")
47453
+ sessionId = configuration.sessionId;
47454
+ await opened.configure(iceServers);
47316
47455
  current(generation);
47317
- }
47318
- await opened.prepare();
47319
- current(generation);
47320
- if (deadline) {
47321
- await opened.grant(deadline);
47456
+ if (deadline) {
47457
+ await opened.grant(deadline);
47458
+ current(generation);
47459
+ }
47460
+ await updateKeys();
47322
47461
  current(generation);
47462
+ markDone(diag, "call.media.setup", startedAt, { stage: "ready" });
47463
+ } catch (error) {
47464
+ await disposeConnection(await opening.catch(() => null));
47465
+ throw error;
47466
+ } finally {
47467
+ if (openingConnection === opening)
47468
+ openingConnection = null;
47323
47469
  }
47324
- await updateKeys();
47325
- current(generation);
47326
47470
  }
47327
47471
  async function applyDescription(result, required = false) {
47328
47472
  current();
@@ -47385,10 +47529,7 @@ function createCallMediaSession({ port, mode, holder, provider, signal, publish,
47385
47529
  if (!started || !keys)
47386
47530
  return;
47387
47531
  if (mode === "group") {
47388
- if (!sessionId) {
47389
- const session = await provider("session/new", {});
47390
- current();
47391
- sessionId = session.sessionId;
47532
+ if (!published) {
47392
47533
  const offer = await media.offer();
47393
47534
  current();
47394
47535
  const sendMid = media.sendMid;
@@ -47407,48 +47548,15 @@ function createCallMediaSession({ port, mode, holder, provider, signal, publish,
47407
47548
  current();
47408
47549
  await connected();
47409
47550
  current();
47410
- await publish({ sessionId, trackName });
47411
- current();
47412
- }
47413
- const additions = peers.filter((peer) => peer.holder !== holder && peer.media?.sessionId && peer.media?.trackName && !received.has(peer.holder));
47414
- if (additions.length) {
47415
- await connected();
47416
- current();
47417
- const result = await provider("tracks/new", { sessionId, tracks: additions.map((peer) => ({
47418
- location: "remote",
47419
- sessionId: peer.media.sessionId,
47420
- trackName: peer.media.trackName
47421
- })) });
47422
- current();
47423
- if (!Array.isArray(result.tracks) || result.tracks.length !== additions.length)
47424
- throw new Error("peer audio subscription failed");
47425
- const mappings = [];
47426
- const occupied = new Set([media.sendMid, ...received.values()]);
47427
- for (const peer of additions) {
47428
- const track = result.tracks?.find((item) => item.sessionId === peer.media.sessionId && item.trackName === peer.media.trackName);
47429
- if (!validMid(track?.mid) || track.errorCode || occupied.has(track.mid))
47430
- throw new Error("peer audio subscription failed");
47431
- occupied.add(track.mid);
47432
- mappings.push([peer.holder, track.mid]);
47433
- }
47434
- for (const [peer, mid] of mappings)
47435
- received.set(peer, mid);
47436
- updateActivity();
47437
- await applyPeerAudio();
47438
- current();
47439
- await updateKeys();
47440
- current();
47441
- await applyDescription(result);
47442
- }
47443
- const removals = [...received].filter(([peer]) => !peers.some((item) => item.holder === peer));
47444
- if (removals.length) {
47445
- for (const [peer] of removals)
47446
- received.delete(peer);
47447
- updateActivity();
47448
- await updateKeys();
47449
- current();
47450
- const result = await provider("tracks/close", { sessionId, tracks: removals.map(([, mid]) => ({ mid })) });
47451
- await applyDescription(result);
47551
+ await Promise.all([
47552
+ publish({ sessionId, trackName }).then(() => {
47553
+ current();
47554
+ published = true;
47555
+ }),
47556
+ receiveGroupTracks()
47557
+ ]);
47558
+ } else {
47559
+ await receiveGroupTracks();
47452
47560
  }
47453
47561
  } else {
47454
47562
  const peer = peers.find((item) => item.holder !== holder);
@@ -47476,6 +47584,48 @@ function createCallMediaSession({ port, mode, holder, provider, signal, publish,
47476
47584
  }
47477
47585
  }
47478
47586
  }
47587
+ async function receiveGroupTracks() {
47588
+ const additions = peers.filter((peer) => peer.holder !== holder && peer.media?.sessionId && peer.media?.trackName && !received.has(peer.holder));
47589
+ if (additions.length) {
47590
+ await connected();
47591
+ current();
47592
+ const result = await provider("tracks/new", { sessionId, tracks: additions.map((peer) => ({
47593
+ location: "remote",
47594
+ sessionId: peer.media.sessionId,
47595
+ trackName: peer.media.trackName
47596
+ })) });
47597
+ current();
47598
+ if (!Array.isArray(result.tracks) || result.tracks.length !== additions.length)
47599
+ throw new Error("peer audio subscription failed");
47600
+ const mappings = [];
47601
+ const occupied = new Set([media.sendMid, ...received.values()]);
47602
+ for (const peer of additions) {
47603
+ const track = result.tracks?.find((item) => item.sessionId === peer.media.sessionId && item.trackName === peer.media.trackName);
47604
+ if (!validMid(track?.mid) || track.errorCode || occupied.has(track.mid))
47605
+ throw new Error("peer audio subscription failed");
47606
+ occupied.add(track.mid);
47607
+ mappings.push([peer.holder, track.mid]);
47608
+ }
47609
+ for (const [peer, mid] of mappings)
47610
+ received.set(peer, mid);
47611
+ updateActivity();
47612
+ await applyPeerAudio();
47613
+ current();
47614
+ await updateKeys();
47615
+ current();
47616
+ await applyDescription(result);
47617
+ }
47618
+ const removals = [...received].filter(([peer]) => !peers.some((item) => item.holder === peer));
47619
+ if (removals.length) {
47620
+ for (const [peer] of removals)
47621
+ received.delete(peer);
47622
+ updateActivity();
47623
+ await updateKeys();
47624
+ current();
47625
+ const result = await provider("tracks/close", { sessionId, tracks: removals.map(([, mid]) => ({ mid })) });
47626
+ await applyDescription(result);
47627
+ }
47628
+ }
47479
47629
  return Object.freeze({
47480
47630
  async open() {
47481
47631
  current();
@@ -47531,13 +47681,19 @@ function createCallMediaSession({ port, mode, holder, provider, signal, publish,
47531
47681
  },
47532
47682
  mute(value) {
47533
47683
  current();
47534
- muted = value === true;
47684
+ const next = value === true;
47685
+ if (muted === next)
47686
+ return;
47687
+ muted = next;
47535
47688
  updateActivity();
47536
47689
  return media?.mute(muted);
47537
47690
  },
47538
47691
  deafen(value) {
47539
47692
  current();
47540
- deafened = value === true;
47693
+ const next = value === true;
47694
+ if (deafened === next)
47695
+ return;
47696
+ deafened = next;
47541
47697
  updateActivity();
47542
47698
  return media?.deafen(deafened);
47543
47699
  },
@@ -47722,7 +47878,7 @@ var initial = () => ({ phase: "idle", chatId: null, callId: null, joiningChatId:
47722
47878
  var cancelled2 = () => Object.assign(new Error("call cancelled"), { code: "calls/cancelled" });
47723
47879
  var ended = () => Object.assign(new Error("this call has ended"), { code: "calls/ended" });
47724
47880
  var callError = (chatId, error) => ({ chatId, code: error?.code || "calls/failed", message: error?.message || "could not connect the call" });
47725
- function createCallSession({ cloud, chat, media: mediaPort, mls, diag }) {
47881
+ function createCallSession({ cloud, chat, media: mediaPort, mls, savePeerAudio, saveAudio, diag }) {
47726
47882
  const listeners = new Set;
47727
47883
  const discoveries = new Map;
47728
47884
  const discoveryReads = new Map;
@@ -47730,11 +47886,16 @@ function createCallSession({ cloud, chat, media: mediaPort, mls, diag }) {
47730
47886
  const retiring = new Set;
47731
47887
  let state = initial();
47732
47888
  const confirmedPeerAudio = new Map;
47889
+ const pendingPeerAudio = new Map;
47890
+ let peerAudioSource = null;
47891
+ let audioSource = normalizeCallAudio();
47892
+ let pendingAudio = null;
47733
47893
  let identity = null;
47734
47894
  let endpoint = null;
47735
47895
  let leaseEndpoint = null;
47736
47896
  let lease = null;
47737
47897
  let active = null;
47898
+ let participation = null;
47738
47899
  let focused = null;
47739
47900
  let generation = 0;
47740
47901
  let joinIntent = 0;
@@ -47809,6 +47970,8 @@ function createCallSession({ cloud, chat, media: mediaPort, mls, diag }) {
47809
47970
  emit({ available: next });
47810
47971
  }
47811
47972
  function closeDiscovery(entry) {
47973
+ if ([...retiring].some((call) => call.discovery === entry))
47974
+ return;
47812
47975
  entry.mailbox.close();
47813
47976
  entry.observation?.close();
47814
47977
  entry.observation = null;
@@ -47934,8 +48097,14 @@ function createCallSession({ cloud, chat, media: mediaPort, mls, diag }) {
47934
48097
  throw Object.assign(new Error("chat unavailable"), { code: "unavailable" });
47935
48098
  if (!epochState)
47936
48099
  throw Object.assign(new Error("chat unavailable"), { code: "calls/chat-not-ready" });
48100
+ if (chat.getSnapshot().getOwnerChat(chatId)?.epochState?.manifest.epochId !== epochId)
48101
+ throw cancelled2();
48102
+ const existing = discoveries.get(chatId);
47937
48103
  const entry = discovery(chatId, epochState);
47938
- await entry.mailbox.read();
48104
+ if (entry === existing)
48105
+ await entry.mailbox.read();
48106
+ else
48107
+ await entry.mailbox.ready();
47939
48108
  current(token);
47940
48109
  return entry;
47941
48110
  }).finally(() => {
@@ -47969,49 +48138,68 @@ function createCallSession({ cloud, chat, media: mediaPort, mls, diag }) {
47969
48138
  prune();
47970
48139
  };
47971
48140
  }
47972
- async function joinAttempt(chatId, intent, expectedCallId, onlyExisting) {
48141
+ async function joinAttempt(chatId, intent, expectedCallId, onlyExisting, continuation = null) {
47973
48142
  if (!identity || !mediaPort || !mls || !cloud.calls)
47974
48143
  throw new Error("calling unavailable");
47975
- if (!foreground)
47976
- throw new Error("open veyl to join a call");
47977
48144
  if (active?.chatId === chatId)
47978
48145
  return chatId;
47979
48146
  const token = generation;
47980
48147
  const startedAt = Date.now();
47981
48148
  const step = (stage) => diag?.("call.join.stage", { stage, elapsedMs: Date.now() - startedAt });
48149
+ let preflightActive = true;
47982
48150
  const check = () => {
47983
48151
  current(token);
47984
- if (intent !== joinIntent || !foreground)
48152
+ if (!preflightActive || intent !== joinIntent)
47985
48153
  throw cancelled2();
47986
48154
  };
47987
- emit({ ...!active ? { phase: "joining", chatId } : {}, error: null });
47988
- await mediaPort.prepare?.();
47989
- check();
47990
- step("permission");
47991
- if (!onlyExisting)
47992
- chatId = await chat.getSnapshot().materializeChat(chatId);
47993
- check();
47994
- joining.chatId = chatId;
47995
- step("chat");
48155
+ const operation = joining;
48156
+ let preparation;
47996
48157
  let call;
47997
48158
  try {
47998
- const epochState = await chat.getSnapshot().prepareCall(chatId);
47999
- check();
48000
- const entry = await readDiscovery(chatId, epochState);
48159
+ emit({ ...!active && !continuation ? { phase: "joining", chatId } : {}, error: null });
48160
+ const permission = (async () => {
48161
+ const value = continuation ? undefined : await mediaPort.prepare?.();
48162
+ try {
48163
+ check();
48164
+ } catch (error) {
48165
+ value?.close();
48166
+ throw error;
48167
+ }
48168
+ preparation = value;
48169
+ operation.preparation = value;
48170
+ step("permission");
48171
+ })();
48172
+ const destination = (async () => {
48173
+ if (!onlyExisting && !continuation)
48174
+ chatId = await chat.getSnapshot().materializeChat(chatId);
48175
+ check();
48176
+ joining.chatId = chatId;
48177
+ step("chat");
48178
+ const epochState = continuation ? chat.getSnapshot().getOwnerChat(chatId)?.epochState : await chat.getSnapshot().prepareCall(chatId);
48179
+ check();
48180
+ if (!epochState)
48181
+ throw cancelled2();
48182
+ const entry2 = await readDiscovery(chatId, epochState);
48183
+ check();
48184
+ step("discovery");
48185
+ return entry2;
48186
+ })();
48187
+ const [, entry] = await Promise.all([permission, destination]);
48001
48188
  check();
48002
48189
  const discoveryRecord = entry.mailbox.getSnapshot();
48003
- step("discovery");
48004
48190
  const create = !entry.descriptor;
48005
48191
  if (expectedCallId && (onlyExisting || entry.epochState.manifest.lineage === "direct") && entry.descriptor?.callId !== expectedCallId)
48006
48192
  throw ended();
48007
48193
  const descriptor = entry.descriptor || {
48008
- callId: toHex(randomBytes3(32)),
48194
+ callId: continuation?.callId || toHex(randomBytes3(32)),
48009
48195
  secret: toHex(randomBytes3(32)),
48010
48196
  mode: entry.epochState.manifest.lineage,
48011
48197
  participants: 1,
48012
- startedAt: Date.now()
48198
+ startedAt: continuation?.startedAt ?? Date.now()
48013
48199
  };
48014
- const callEndpoint = createCallEndpoint();
48200
+ if (continuation && descriptor.callId !== continuation.callId)
48201
+ throw ended();
48202
+ const callEndpoint = continuation?.endpoint || createCallEndpoint();
48015
48203
  call = {
48016
48204
  chatId,
48017
48205
  callId: descriptor.callId,
@@ -48032,6 +48220,7 @@ function createCallSession({ cloud, chat, media: mediaPort, mls, diag }) {
48032
48220
  muteTail: Promise.resolve()
48033
48221
  };
48034
48222
  joining.candidate = call;
48223
+ call.owner = continuation?.owner || createLease(identity, callEndpoint);
48035
48224
  const protocol = createCallProtocol({ realm: cloud.environment, epochState: entry.epochState, identity, endpoint: callEndpoint, callId: descriptor.callId });
48036
48225
  call.protocol = protocol;
48037
48226
  const ownMember = (value) => {
@@ -48051,6 +48240,7 @@ function createCallSession({ cloud, chat, media: mediaPort, mls, diag }) {
48051
48240
  if (!create)
48052
48241
  cleanBytes(callMember.snapshot);
48053
48242
  check();
48243
+ step("identity");
48054
48244
  const admission = protocol.sign("join", { keyPackage: member.keyPackage ? encodeCallBytes(member.keyPackage) : "", signaturePK: toHex(member.signaturePK) });
48055
48245
  const secret = fromHexBytes(descriptor.secret);
48056
48246
  const capability = createCallCapability(secret, cloud.environment, "signaling", [descriptor.callId]);
@@ -48082,8 +48272,10 @@ function createCallSession({ cloud, chat, media: mediaPort, mls, diag }) {
48082
48272
  };
48083
48273
  call.media = createCallMediaSession({
48084
48274
  port: mediaPort,
48275
+ preparation,
48085
48276
  mode: descriptor.mode,
48086
48277
  holder: callEndpoint.holder,
48278
+ diag,
48087
48279
  provider: (...args) => call.owner.provider(...args),
48088
48280
  signal: (...args) => call.room.signal(...args),
48089
48281
  publish: (value) => call.room.publishMedia(value),
@@ -48098,6 +48290,8 @@ function createCallSession({ cloud, chat, media: mediaPort, mls, diag }) {
48098
48290
  participants: call.participants.length
48099
48291
  });
48100
48292
  if (value.state === "connected") {
48293
+ if (state.phase !== "connected")
48294
+ step("connected");
48101
48295
  clearTimeout(call.joinTimer);
48102
48296
  call.joinTimer = null;
48103
48297
  emit({ phase: "connected", transport: value.transport || (descriptor.mode === "group" ? "relay" : null) });
@@ -48194,7 +48388,8 @@ function createCallSession({ cloud, chat, media: mediaPort, mls, diag }) {
48194
48388
  throw error;
48195
48389
  }
48196
48390
  checkCall();
48197
- announce(chatId, descriptor.callId, "started");
48391
+ if (!continuation)
48392
+ announce(chatId, descriptor.callId, "started");
48198
48393
  } else {
48199
48394
  await entry.mailbox.read();
48200
48395
  checkCall();
@@ -48204,16 +48399,21 @@ function createCallSession({ cloud, chat, media: mediaPort, mls, diag }) {
48204
48399
  throw Object.assign(new Error("the call room changed"), { code: "calls/discovery-conflict" });
48205
48400
  }
48206
48401
  }
48402
+ step("publication");
48207
48403
  call.published = true;
48208
- await stopCall();
48209
- checkCall();
48210
- mountLease(identity, callEndpoint);
48211
- call.owner = lease;
48404
+ if (!continuation) {
48405
+ await stopCall();
48406
+ checkCall();
48407
+ mountLease(identity, callEndpoint, call.owner);
48408
+ participation = { chatId, callId: descriptor.callId, startedAt: descriptor.startedAt, endpoint: callEndpoint, owner: lease };
48409
+ }
48410
+ step("predecessor");
48411
+ call.owner = participation.owner;
48212
48412
  active = call;
48213
48413
  observeRoster(entry);
48214
48414
  available();
48215
48415
  emit({
48216
- phase: state.elsewhere ? "transferring" : "joining",
48416
+ phase: continuation ? "connecting" : state.elsewhere ? "transferring" : "joining",
48217
48417
  chatId,
48218
48418
  callId: descriptor.callId,
48219
48419
  mode: descriptor.mode,
@@ -48226,7 +48426,10 @@ function createCallSession({ cloud, chat, media: mediaPort, mls, diag }) {
48226
48426
  if (preference)
48227
48427
  call.media.setPeerAudio(peer.holder, { volume: preference.volume / 100, muted: preference.muted }).catch((error) => diag?.("call.peer.audio.error", { code: error?.code || "calls/audio" }));
48228
48428
  }
48229
- await lease.acquire({ chatId, callId: descriptor.callId });
48429
+ if (continuation)
48430
+ await participation.owner.rotateMedia();
48431
+ else
48432
+ await lease.acquire({ chatId, callId: descriptor.callId });
48230
48433
  check();
48231
48434
  step("ownership");
48232
48435
  if (!valid())
@@ -48286,6 +48489,11 @@ function createCallSession({ cloud, chat, media: mediaPort, mls, diag }) {
48286
48489
  throw Object.assign(new Error("the call room is restarting"), { code: "calls/discovery-conflict" });
48287
48490
  }
48288
48491
  throw error;
48492
+ } finally {
48493
+ preflightActive = false;
48494
+ preparation?.close();
48495
+ if (operation.preparation === preparation)
48496
+ operation.preparation = null;
48289
48497
  }
48290
48498
  }
48291
48499
  async function join(chatId, expectedCallId = null, onlyExisting = false) {
@@ -48294,21 +48502,31 @@ function createCallSession({ cloud, chat, media: mediaPort, mls, diag }) {
48294
48502
  throw ended();
48295
48503
  return chatId;
48296
48504
  }
48505
+ if (!foreground)
48506
+ throw new Error("open veyl to join a call");
48507
+ return runJoin(chatId, expectedCallId, onlyExisting);
48508
+ }
48509
+ async function runJoin(chatId, expectedCallId = null, onlyExisting = false, continuation = null) {
48297
48510
  const intent = ++joinIntent;
48298
- const operation = { predecessor: active, chatId, candidate: null };
48511
+ const operation = { predecessor: active, chatId, candidate: null, continuation };
48299
48512
  const previous = joining;
48300
48513
  joining = operation;
48514
+ previous?.preparation?.close();
48301
48515
  emit({ joiningChatId: chatId });
48302
48516
  if (previous?.candidate && previous.candidate !== active)
48303
48517
  disposeCall(previous.candidate);
48304
48518
  try {
48305
48519
  for (let attempt = 0;attempt < 3; attempt += 1) {
48306
48520
  try {
48307
- return await joinAttempt(chatId, intent, expectedCallId, onlyExisting);
48521
+ return await joinAttempt(chatId, intent, expectedCallId, onlyExisting, continuation);
48308
48522
  } catch (error) {
48309
48523
  if (intent !== joinIntent || !identity || closed)
48310
48524
  throw cancelled2();
48311
48525
  if (error?.code !== "calls/discovery-conflict" || attempt === 2) {
48526
+ if (continuation) {
48527
+ fail(error);
48528
+ throw error;
48529
+ }
48312
48530
  const target = operation.chatId;
48313
48531
  emit({ ...!active ? { phase: "error", chatId: target } : {}, error: callError(chatId, error) });
48314
48532
  throw error;
@@ -48325,6 +48543,7 @@ function createCallSession({ cloud, chat, media: mediaPort, mls, diag }) {
48325
48543
  }
48326
48544
  function leave() {
48327
48545
  joinIntent += 1;
48546
+ joining?.preparation?.close();
48328
48547
  const candidate = joining?.candidate;
48329
48548
  joining = null;
48330
48549
  emit({ joiningChatId: null });
@@ -48339,6 +48558,7 @@ function createCallSession({ cloud, chat, media: mediaPort, mls, diag }) {
48339
48558
  if (!operation)
48340
48559
  return Promise.resolve();
48341
48560
  joinIntent += 1;
48561
+ operation.preparation?.close();
48342
48562
  joining = null;
48343
48563
  emit({ joiningChatId: null });
48344
48564
  const pending = operation.candidate && operation.candidate !== active ? disposeCall(operation.candidate) : null;
@@ -48348,6 +48568,14 @@ function createCallSession({ cloud, chat, media: mediaPort, mls, diag }) {
48348
48568
  }
48349
48569
  function stopCall() {
48350
48570
  const call = active;
48571
+ const authority = participation;
48572
+ participation = null;
48573
+ if (!call && authority) {
48574
+ authority.owner.retire();
48575
+ const retired = [...retiring].filter((room) => room.endpoint === authority.endpoint);
48576
+ const released = Promise.all(retired.map((room) => room.stopped)).then(() => authority.owner.release());
48577
+ leaving = retired.length ? Promise.race([released, Promise.all(retired.map((room) => room.closing))]) : released;
48578
+ }
48351
48579
  if (!call) {
48352
48580
  emit({ phase: "idle", chatId: null, callId: null, mode: null, transport: null, participants: [], speaking: [] });
48353
48581
  return leaving;
@@ -48367,7 +48595,9 @@ function createCallSession({ cloud, chat, media: mediaPort, mls, diag }) {
48367
48595
  return;
48368
48596
  try {
48369
48597
  await entry.mailbox.commit(null, [], { expectedRevision: snapshot.revision });
48370
- announce(call.chatId, call.callId, "ended");
48598
+ if (chat.getSnapshot().getOwnerChat(call.chatId)?.epochState?.manifest.epochId === entry.epochState.manifest.epochId) {
48599
+ announce(call.chatId, call.callId, "ended");
48600
+ }
48371
48601
  } catch (error) {
48372
48602
  if (error?.code !== "calls/conflict")
48373
48603
  throw error;
@@ -48387,11 +48617,13 @@ function createCallSession({ cloud, chat, media: mediaPort, mls, diag }) {
48387
48617
  clearTimeout(call.timer);
48388
48618
  clearTimeout(call.joinTimer);
48389
48619
  const stopping = call.media?.close();
48390
- call.owner?.retire();
48620
+ call.stopped = Promise.resolve(stopping);
48621
+ const releasedOwner = participation?.endpoint === call.endpoint ? null : call.owner;
48622
+ releasedOwner?.retire();
48391
48623
  const departing = call.room?.leave();
48392
48624
  const draining = (async () => {
48393
48625
  try {
48394
- const [, departure] = await Promise.allSettled([Promise.resolve(stopping).then(() => call.owner?.release()), departing]);
48626
+ const [, departure] = await Promise.allSettled([Promise.resolve(stopping).then(() => releasedOwner?.release()), departing]);
48395
48627
  if (departure.status === "fulfilled" && departure.value?.ended && call.published && call.discovery.descriptor?.callId === call.callId) {
48396
48628
  await retireDiscovery(call);
48397
48629
  }
@@ -48411,9 +48643,13 @@ function createCallSession({ cloud, chat, media: mediaPort, mls, diag }) {
48411
48643
  call.room?.close();
48412
48644
  call.protocol?.close();
48413
48645
  cleanBytes(call.member?.snapshot);
48646
+ if (call.owner !== lease && call.owner !== participation?.owner)
48647
+ call.owner?.close();
48414
48648
  if (call.endpoint !== leaseEndpoint)
48415
48649
  call.endpoint.close();
48416
48650
  retiring.delete(call);
48651
+ if (discoveries.get(call.chatId) !== call.discovery)
48652
+ closeDiscovery(call.discovery);
48417
48653
  prune();
48418
48654
  });
48419
48655
  return call.closing;
@@ -48429,6 +48665,27 @@ function createCallSession({ cloud, chat, media: mediaPort, mls, diag }) {
48429
48665
  return result;
48430
48666
  }
48431
48667
  async function setAudio(patch) {
48668
+ const value = normalizeCallAudio(patch, state);
48669
+ if (value.muted === state.muted && value.deafened === state.deafened && value.muted === audioSource.muted && value.deafened === audioSource.deafened)
48670
+ return;
48671
+ const token = generation;
48672
+ pendingAudio = value;
48673
+ const saving = Promise.resolve().then(() => {
48674
+ if (generation !== token)
48675
+ throw cancelled2();
48676
+ return saveAudio(value);
48677
+ }).then(() => {
48678
+ if (generation === token)
48679
+ audioSource = value;
48680
+ });
48681
+ try {
48682
+ await Promise.all([saving, applyAudioPreferences(value)]);
48683
+ } finally {
48684
+ if (generation === token && pendingAudio === value)
48685
+ pendingAudio = null;
48686
+ }
48687
+ }
48688
+ async function applyAudioPreferences(patch) {
48432
48689
  emit(patch);
48433
48690
  const calls = new Set([active, joining?.candidate].filter((call) => call?.media && call.room && !call.leaving));
48434
48691
  await Promise.all([...calls].map((call) => applyAudio(call).catch((error) => {
@@ -48439,6 +48696,13 @@ function createCallSession({ cloud, chat, media: mediaPort, mls, diag }) {
48439
48696
  throw error;
48440
48697
  })));
48441
48698
  }
48699
+ function syncAudio(source) {
48700
+ audioSource = normalizeCallAudio(source);
48701
+ const value = pendingAudio || audioSource;
48702
+ if (state.muted === value.muted && state.deafened === value.deafened)
48703
+ return;
48704
+ applyAudioPreferences(value).catch((error) => diag?.("call.audio.settings.error", { code: error?.code || "calls/audio" }));
48705
+ }
48442
48706
  async function setPeerAudio(chatPK, patch) {
48443
48707
  const call = active;
48444
48708
  if (!identity || typeof chatPK !== "string" || !chatPK || chatPK === identity.chatPK)
@@ -48447,11 +48711,15 @@ function createCallSession({ cloud, chat, media: mediaPort, mls, diag }) {
48447
48711
  const peer = call?.participants.find((peer2) => peer2.chatPK === chatPK);
48448
48712
  const previous = state.peerAudio[chatPK];
48449
48713
  const value = { volume: 100, muted: false, ...previous, ...patch };
48714
+ pendingPeerAudio.set(chatPK, value);
48450
48715
  emit({ peerAudio: { ...state.peerAudio, [chatPK]: value } });
48451
48716
  try {
48452
48717
  if (peer)
48453
48718
  await call.media.setPeerAudio(peer.holder, { volume: value.volume / 100, muted: value.muted });
48454
- if (generation === token && state.peerAudio[chatPK] === value)
48719
+ if (generation !== token || pendingPeerAudio.get(chatPK) !== value)
48720
+ return;
48721
+ await savePeerAudio(chatPK, value);
48722
+ if (generation === token)
48455
48723
  confirmedPeerAudio.set(chatPK, value);
48456
48724
  } catch (error) {
48457
48725
  if (generation === token && state.peerAudio[chatPK] === value) {
@@ -48462,8 +48730,39 @@ function createCallSession({ cloud, chat, media: mediaPort, mls, diag }) {
48462
48730
  else
48463
48731
  delete peerAudio[chatPK];
48464
48732
  emit({ peerAudio });
48733
+ if (peer && active === call)
48734
+ call.media.setPeerAudio(peer.holder, {
48735
+ volume: (confirmed?.volume ?? 100) / 100,
48736
+ muted: confirmed?.muted ?? false
48737
+ }).catch(() => {});
48465
48738
  }
48466
48739
  throw error;
48740
+ } finally {
48741
+ if (generation === token && pendingPeerAudio.get(chatPK) === value)
48742
+ pendingPeerAudio.delete(chatPK);
48743
+ }
48744
+ }
48745
+ function syncPeerAudio(source = {}) {
48746
+ if (source === peerAudioSource)
48747
+ return;
48748
+ peerAudioSource = source;
48749
+ const peerAudio = { ...source };
48750
+ for (const [chatPK, value] of pendingPeerAudio)
48751
+ peerAudio[chatPK] = value;
48752
+ confirmedPeerAudio.clear();
48753
+ for (const [chatPK, value] of Object.entries(source))
48754
+ confirmedPeerAudio.set(chatPK, value);
48755
+ const changed = new Set([...Object.keys(state.peerAudio), ...Object.keys(peerAudio)].filter((chatPK) => state.peerAudio[chatPK]?.volume !== peerAudio[chatPK]?.volume || state.peerAudio[chatPK]?.muted !== peerAudio[chatPK]?.muted));
48756
+ if (!changed.size)
48757
+ return;
48758
+ emit({ peerAudio });
48759
+ for (const call of new Set([active, joining?.candidate].filter((call2) => call2?.media && !call2.leaving))) {
48760
+ for (const peer of call.participants) {
48761
+ if (!changed.has(peer.chatPK))
48762
+ continue;
48763
+ const audio = peerAudio[peer.chatPK];
48764
+ call.media.setPeerAudio(peer.holder, { volume: (audio?.volume ?? 100) / 100, muted: audio?.muted ?? false }).catch((error) => diag?.("call.peer.audio.error", { code: error?.code || "calls/audio" }));
48765
+ }
48467
48766
  }
48468
48767
  }
48469
48768
  async function setMicrophoneVolume(volume) {
@@ -48481,13 +48780,9 @@ function createCallSession({ cloud, chat, media: mediaPort, mls, diag }) {
48481
48780
  throw error;
48482
48781
  }
48483
48782
  }
48484
- function mountLease(session, callEndpoint = endpoint) {
48485
- lease?.close();
48486
- if (leaseEndpoint && leaseEndpoint !== endpoint && leaseEndpoint !== callEndpoint)
48487
- leaseEndpoint.close();
48488
- leaseEndpoint = callEndpoint;
48783
+ function createLease(session, callEndpoint) {
48489
48784
  const ownsLease = () => identity === session && leaseEndpoint === callEndpoint;
48490
- lease = openAccountCallLease({
48785
+ return openAccountCallLease({
48491
48786
  cloud,
48492
48787
  capability: session.callIdentity,
48493
48788
  endpoint: callEndpoint,
@@ -48512,9 +48807,22 @@ function createCallSession({ cloud, chat, media: mediaPort, mls, diag }) {
48512
48807
  }
48513
48808
  });
48514
48809
  }
48515
- function setSources(session) {
48516
- if (identity === session)
48810
+ function mountLease(session, callEndpoint = endpoint, prepared = null) {
48811
+ lease?.close();
48812
+ if (leaseEndpoint && leaseEndpoint !== endpoint && leaseEndpoint !== callEndpoint)
48813
+ leaseEndpoint.close();
48814
+ leaseEndpoint = callEndpoint;
48815
+ lease = prepared || createLease(session, callEndpoint);
48816
+ }
48817
+ function setSources(session, settings = {}) {
48818
+ const peerAudio = settings.peerAudio || {};
48819
+ if (identity === session) {
48820
+ if (session) {
48821
+ syncPeerAudio(peerAudio);
48822
+ syncAudio(settings.callAudio);
48823
+ }
48517
48824
  return drainingSources;
48825
+ }
48518
48826
  const token = ++generation;
48519
48827
  const previousLease = lease;
48520
48828
  const previousEndpoint = endpoint;
@@ -48546,7 +48854,13 @@ function createCallSession({ cloud, chat, media: mediaPort, mls, diag }) {
48546
48854
  if (generation !== token || identity !== session)
48547
48855
  return retirement;
48548
48856
  confirmedPeerAudio.clear();
48549
- emit(initial());
48857
+ pendingPeerAudio.clear();
48858
+ pendingAudio = null;
48859
+ audioSource = normalizeCallAudio(settings.callAudio);
48860
+ peerAudioSource = session ? peerAudio : null;
48861
+ for (const [chatPK, value] of Object.entries(peerAudioSource || {}))
48862
+ confirmedPeerAudio.set(chatPK, value);
48863
+ emit({ ...initial(), ...session ? audioSource : {}, peerAudio: peerAudioSource || {} });
48550
48864
  available();
48551
48865
  if (generation !== token || identity !== session)
48552
48866
  return retirement;
@@ -48567,10 +48881,35 @@ function createCallSession({ cloud, chat, media: mediaPort, mls, diag }) {
48567
48881
  if (!identity)
48568
48882
  return;
48569
48883
  for (const [chatId, entry] of discoveries) {
48570
- const epoch = chat.getSnapshot().getOwnerChat(chatId)?.epochState?.manifest.epochId;
48571
- if (epoch === entry.epochState.manifest.epochId)
48884
+ const epochState = chat.getSnapshot().getOwnerChat(chatId)?.epochState;
48885
+ if (epochState?.manifest.epochId === entry.epochState.manifest.epochId)
48572
48886
  continue;
48573
- if (active?.chatId === chatId)
48887
+ const retained = epochState?.manifest.members.some((member) => member.chatSigningPK === identity.chatSigningPK);
48888
+ if (participation?.chatId === chatId && retained) {
48889
+ const continuation = participation;
48890
+ const intent = ++joinIntent;
48891
+ const previous = active || joining?.candidate;
48892
+ active = null;
48893
+ joining = null;
48894
+ if (previous)
48895
+ disposeCall(previous);
48896
+ const members = new Set(epochState.manifest.members.map((member) => member.chatPK));
48897
+ emit({
48898
+ phase: "connecting",
48899
+ joiningChatId: chatId,
48900
+ speaking: [],
48901
+ participants: state.participants.filter((member) => members.has(member.chatPK))
48902
+ });
48903
+ diag?.("call.epoch.transition", { stage: "starting", participants: state.participants.length });
48904
+ Promise.resolve(previous?.stopped).then(() => {
48905
+ if (intent !== joinIntent || participation !== continuation)
48906
+ return;
48907
+ return runJoin(chatId, null, false, continuation);
48908
+ }).catch((error) => {
48909
+ if (intent === joinIntent && participation === continuation)
48910
+ fail(error);
48911
+ });
48912
+ } else if (active?.chatId === chatId || participation?.chatId === chatId)
48574
48913
  leave();
48575
48914
  else if (joining?.chatId === chatId)
48576
48915
  cancelJoin();
@@ -48629,8 +48968,6 @@ function createCallSession({ cloud, chat, media: mediaPort, mls, diag }) {
48629
48968
  },
48630
48969
  setForeground(value) {
48631
48970
  foreground = value === true;
48632
- if (!foreground && joining)
48633
- cancelJoin();
48634
48971
  },
48635
48972
  setOnline(value) {
48636
48973
  const reconnecting = !online && value === true;
@@ -51652,15 +51989,17 @@ var DEFAULT_BITCOIN = Object.freeze({
51652
51989
  ready: false,
51653
51990
  error: null
51654
51991
  });
51655
- function normalizeBitcoinData(data, current = DEFAULT_BITCOIN, info = {}) {
51992
+ function normalizeBitcoinData(data, current = DEFAULT_BITCOIN) {
51656
51993
  if (!data)
51657
51994
  return current;
51995
+ const observedAt = timestampMs(data.priceUpdatedAt, null, { positive: true });
51996
+ const usePrice = hasBitcoinPrice(data.price) && observedAt != null && observedAt >= (current.priceUpdatedAt ?? 0);
51658
51997
  return {
51659
- price: hasBitcoinPrice(data.price) ? data.price : current.price,
51660
- priceUpdatedAt: hasBitcoinPrice(data.price) ? timestampMs(data.updatedAt, Date.now()) : current.priceUpdatedAt,
51661
- priceFromCache: hasBitcoinPrice(data.price) ? info.fromCache === true : current.priceFromCache,
51998
+ price: usePrice ? data.price : current.price,
51999
+ priceUpdatedAt: usePrice ? observedAt : current.priceUpdatedAt,
52000
+ priceFromCache: usePrice ? false : current.priceFromCache,
51662
52001
  block: data.block ?? current?.block ?? null,
51663
- fees: data.fees ?? current?.fees ?? null,
52002
+ fees: data.fees ?? null,
51664
52003
  updatedAt: data.updatedAt ?? current?.updatedAt ?? null,
51665
52004
  ready: true,
51666
52005
  error: null
@@ -51710,20 +52049,15 @@ function createBitcoin({ cloud, priceStorage = null, diag = null }) {
51710
52049
  const priceUpdatedAt = timestampMs(cached.updatedAt, null, { positive: true });
51711
52050
  if (!priceUpdatedAt)
51712
52051
  return;
51713
- if (bitcoin.priceUpdatedAt != null && (!bitcoin.priceFromCache || bitcoin.priceUpdatedAt >= priceUpdatedAt))
52052
+ if (bitcoin.priceUpdatedAt != null && bitcoin.priceUpdatedAt >= priceUpdatedAt)
51714
52053
  return;
51715
52054
  publish({ ...bitcoin, price: cached.price, priceUpdatedAt, priceFromCache: true });
51716
52055
  }).catch((error) => diag?.("bitcoin.price.cache.read.error", { code: error?.code || "" }));
51717
- unsubscribe = cloud.bitcoin.watch((data, info = {}) => {
51718
- if (session !== generation || info.pending)
52056
+ unsubscribe = cloud.bitcoin.watch((data) => {
52057
+ if (session !== generation)
51719
52058
  return;
51720
- const next = normalizeBitcoinData(data, bitcoin, info);
51721
- if (info.fromCache && bitcoin.priceUpdatedAt != null && next.priceUpdatedAt <= bitcoin.priceUpdatedAt) {
51722
- next.price = bitcoin.price;
51723
- next.priceUpdatedAt = bitcoin.priceUpdatedAt;
51724
- next.priceFromCache = bitcoin.priceFromCache;
51725
- }
51726
- if (priceStorage && hasBitcoinPrice(data?.price) && !info.fromCache && (next.price !== bitcoin.price || next.priceUpdatedAt !== bitcoin.priceUpdatedAt || bitcoin.priceFromCache)) {
52059
+ const next = normalizeBitcoinData(data, bitcoin);
52060
+ if (priceStorage && next.priceUpdatedAt != null && !next.priceFromCache && (next.price !== bitcoin.price || next.priceUpdatedAt !== bitcoin.priceUpdatedAt || bitcoin.priceFromCache)) {
51727
52061
  const cached = { price: next.price, updatedAt: next.priceUpdatedAt };
51728
52062
  priceWrite = priceWrite.catch(NOOP3).then(() => priceStorage.write(cached));
51729
52063
  priceWrite.catch((error) => diag?.("bitcoin.price.cache.write.error", { code: error?.code || "" }));
@@ -52854,8 +53188,9 @@ function createPeersApi({ cloud, network, avatarCache = null }) {
52854
53188
  async function fetchProfileByUid(uid) {
52855
53189
  if (!uid)
52856
53190
  return null;
52857
- if (profileCache.has(uid))
52858
- return profileCache.get(uid);
53191
+ const cached = profileCache.get(uid);
53192
+ if (isFullProfile(cached))
53193
+ return cached;
52859
53194
  try {
52860
53195
  const record = await cloud.peer.read(uid);
52861
53196
  if (!record)
@@ -52903,11 +53238,11 @@ function createPeersApi({ cloud, network, avatarCache = null }) {
52903
53238
  return null;
52904
53239
  if (field === "walletPK" && walletToUid.has(value)) {
52905
53240
  const uid = walletToUid.get(value);
52906
- return profileCache.get(uid) ?? null;
53241
+ return fetchProfileByUid(uid);
52907
53242
  }
52908
53243
  if (field === "chatPK" && chatToUid.has(value)) {
52909
53244
  const uid = chatToUid.get(value);
52910
- return profileCache.get(uid) ?? null;
53245
+ return fetchProfileByUid(uid);
52911
53246
  }
52912
53247
  try {
52913
53248
  const record = field === "walletPK" ? await cloud.search.peer.byWalletPK(value, { network: walletNetwork }) : field === "chatPK" ? await cloud.search.peer.byChatPK(value) : await cloud.search.peer.byUsername(value);
@@ -60309,7 +60644,14 @@ function openAccount(options = {}) {
60309
60644
  maintenance: messageMaintenance,
60310
60645
  resolveActiveCall: (chatId) => calls.resolveActiveCall(chatId)
60311
60646
  }, chatSources());
60312
- const calls = createCallSession({ cloud, chat, ...options.calls, diag });
60647
+ const calls = createCallSession({
60648
+ cloud,
60649
+ chat,
60650
+ ...options.calls,
60651
+ diag,
60652
+ savePeerAudio: (chatPK, audio) => user.getSnapshot().updateSettings({ peerAudio: { [chatPK]: audio } }),
60653
+ saveAudio: (callAudio) => user.getSnapshot().updateSettings({ callAudio })
60654
+ });
60313
60655
  const wallet = openWallet({
60314
60656
  ...options.wallet || {},
60315
60657
  network: state.network,
@@ -60383,7 +60725,7 @@ function openAccount(options = {}) {
60383
60725
  syncPresence();
60384
60726
  syncPeers();
60385
60727
  calls.setOnline(state.online);
60386
- calls.setSources(state.lockState === "unlocked" ? state.session : null);
60728
+ calls.setSources(state.lockState === "unlocked" ? state.session : null, state.user?.settings);
60387
60729
  }
60388
60730
  function syncPresence() {
60389
60731
  const session = state.session;