@vex-chat/libvex 7.1.6 → 7.3.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/src/Client.ts CHANGED
@@ -15,6 +15,11 @@ import type {
15
15
  import type { KeyPair } from "@vex-chat/crypto";
16
16
  import type {
17
17
  ActionToken,
18
+ CallAction,
19
+ CallEnvelopeBody,
20
+ CallEvent,
21
+ CallSession,
22
+ CallSignalPayload,
18
23
  ChallMsg,
19
24
  Channel,
20
25
  Device,
@@ -22,8 +27,10 @@ import type {
22
27
  Emoji,
23
28
  FileResponse,
24
29
  FileSQL,
30
+ IceServerConfig,
25
31
  Invite,
26
32
  KeyBundle,
33
+ MailNotificationHint,
27
34
  MailWS,
28
35
  NotifyMsg,
29
36
  Passkey,
@@ -37,6 +44,7 @@ import type {
37
44
  Server,
38
45
  ServerChannelBootstrap,
39
46
  SessionSQL,
47
+ SignedCallEnvelope,
40
48
  } from "@vex-chat/types";
41
49
  import type { ClientMessage } from "@vex-chat/types";
42
50
 
@@ -70,9 +78,13 @@ import {
70
78
  XUtils,
71
79
  } from "@vex-chat/crypto";
72
80
  import {
81
+ CallEnvelopeBodySchema,
82
+ CallEventSchema,
83
+ IceServerConfigSchema,
73
84
  MailType,
74
85
  MailWSSchema,
75
86
  PermissionSchema,
87
+ SignedCallEnvelopeSchema,
76
88
  WSMessageSchema,
77
89
  } from "@vex-chat/types";
78
90
 
@@ -356,6 +368,28 @@ import { uuidToUint8 } from "./utils/uint8uuid.js";
356
368
 
357
369
  const _protocolMsgRegex = /��\w+:\w+��/g;
358
370
 
371
+ /**
372
+ * Voice-call signaling operations.
373
+ *
374
+ * `libvex` moves authenticated call control over Spire. Platform apps own
375
+ * WebRTC/media capture and pass offers, answers, and ICE candidates through
376
+ * these methods.
377
+ */
378
+ export interface Calls {
379
+ accept: (callID: string, signal?: CallSignalPayload) => Promise<CallEvent>;
380
+ active: () => Promise<CallSession[]>;
381
+ cancel: (callID: string) => Promise<CallEvent>;
382
+ hangup: (callID: string) => Promise<CallEvent>;
383
+ ice: (callID: string, signal: CallSignalPayload) => Promise<CallEvent>;
384
+ iceServers: () => Promise<IceServerConfig[]>;
385
+ reject: (callID: string) => Promise<CallEvent>;
386
+ signal: (callID: string, signal: CallSignalPayload) => Promise<CallEvent>;
387
+ startDM: (
388
+ recipientUserID: string,
389
+ signal?: CallSignalPayload,
390
+ ) => Promise<CallEvent>;
391
+ }
392
+
359
393
  /**
360
394
  * @ignore
361
395
  */
@@ -690,7 +724,7 @@ export type { Server } from "@vex-chat/types";
690
724
  export type { ServerChannelBootstrap } from "@vex-chat/types";
691
725
 
692
726
  export interface NotificationSubscription {
693
- channel: "expo";
727
+ channel: NotificationSubscriptionChannel;
694
728
  createdAt: string;
695
729
  deviceID: string;
696
730
  enabled: boolean;
@@ -702,9 +736,17 @@ export interface NotificationSubscription {
702
736
  userID: string;
703
737
  }
704
738
 
739
+ export type NotificationSubscriptionChannel = "apnsVoip" | "expo" | "fcmCall";
740
+
741
+ const NotificationSubscriptionChannelSchema = z.enum([
742
+ "apnsVoip",
743
+ "expo",
744
+ "fcmCall",
745
+ ]);
746
+
705
747
  const NotificationSubscriptionSchema: z.ZodType<NotificationSubscription> =
706
748
  z.object({
707
- channel: z.literal("expo"),
749
+ channel: NotificationSubscriptionChannelSchema,
708
750
  createdAt: z.string(),
709
751
  deviceID: z.string(),
710
752
  enabled: z.boolean(),
@@ -717,7 +759,7 @@ const NotificationSubscriptionSchema: z.ZodType<NotificationSubscription> =
717
759
  });
718
760
 
719
761
  export interface NotificationSubscriptionInput {
720
- channel: "expo";
762
+ channel: NotificationSubscriptionChannel;
721
763
  events?: string[];
722
764
  platform?: "android" | "ios" | "web";
723
765
  token: string;
@@ -899,17 +941,35 @@ const messageSchema: z.ZodType<Message> = z.object({
899
941
  timestamp: z.string(),
900
942
  });
901
943
 
944
+ const CALL_ENVELOPE_PREFIX = "vex-call:1\n";
945
+ const CALL_INVITE_TTL_MS = 60_000;
946
+ const CALL_MAX_TTL_MS = 2 * 60 * 60 * 1000;
902
947
  const MESSAGE_BLOB_PREFIX = "vex-message:1\n";
903
948
  const MAIL_FANOUT_CONCURRENCY = 8;
904
949
  const MAIL_BATCH_MAX_SIZE = 32;
905
950
  const MAIL_BATCH_FLUSH_DELAY_MS = 8;
906
951
 
952
+ interface CallWakeNotifyData {
953
+ callID: string;
954
+ expiresAt?: string | undefined;
955
+ mailID?: string | undefined;
956
+ mailNonce?: string | undefined;
957
+ }
958
+
907
959
  interface DecodedMessagePlaintext {
908
960
  extra?: null | string | undefined;
909
961
  message: string;
910
962
  retentionHintDays?: number | undefined;
911
963
  }
912
964
 
965
+ interface EncryptedCallState {
966
+ peerDeviceID?: string | undefined;
967
+ peerUserID: string;
968
+ pendingPeerDevices: Device[];
969
+ sequence: number;
970
+ session: CallSession;
971
+ }
972
+
913
973
  interface PendingMailBatchDelivery {
914
974
  header: Uint8Array;
915
975
  mail: MailWS;
@@ -931,6 +991,62 @@ const mailBatchResponseSchema = z.object({
931
991
  ),
932
992
  });
933
993
 
994
+ const callWakeNotifyData = z.object({
995
+ callID: z.string(),
996
+ expiresAt: z.string().optional(),
997
+ mailID: z.string().optional(),
998
+ mailNonce: z.string().optional(),
999
+ });
1000
+
1001
+ function canonicalizeJson(value: unknown): unknown {
1002
+ if (Array.isArray(value)) {
1003
+ return value.map((item) => canonicalizeJson(item));
1004
+ }
1005
+ if (!isRecord(value)) {
1006
+ return value;
1007
+ }
1008
+ const out: Record<string, unknown> = {};
1009
+ for (const key of Object.keys(value).sort()) {
1010
+ const item = value[key];
1011
+ if (item !== undefined) {
1012
+ out[key] = canonicalizeJson(item);
1013
+ }
1014
+ }
1015
+ return out;
1016
+ }
1017
+
1018
+ function canonicalJsonBytes(value: unknown): Uint8Array {
1019
+ return XUtils.decodeUTF8(
1020
+ JSON.stringify(canonicalizeJson(jsonWireValue(value))),
1021
+ );
1022
+ }
1023
+
1024
+ function cloneCallSession(session: CallSession): CallSession {
1025
+ return {
1026
+ ...session,
1027
+ participants: session.participants.map((participant) => ({
1028
+ ...participant,
1029
+ })),
1030
+ };
1031
+ }
1032
+
1033
+ function decodeCallEnvelopePlaintext(
1034
+ plaintext: string,
1035
+ ): null | SignedCallEnvelope {
1036
+ if (!plaintext.startsWith(CALL_ENVELOPE_PREFIX)) {
1037
+ return null;
1038
+ }
1039
+ try {
1040
+ const raw = JSON.parse(
1041
+ plaintext.slice(CALL_ENVELOPE_PREFIX.length),
1042
+ ) as unknown;
1043
+ const parsed = SignedCallEnvelopeSchema.safeParse(raw);
1044
+ return parsed.success ? parsed.data : null;
1045
+ } catch {
1046
+ return null;
1047
+ }
1048
+ }
1049
+
934
1050
  function decodeMessageBlob(body: string): DecodedMessagePlaintext {
935
1051
  if (!body.startsWith(MESSAGE_BLOB_PREFIX)) {
936
1052
  return { message: body };
@@ -968,6 +1084,10 @@ function decodeMessagePlaintext(plaintext: string): DecodedMessagePlaintext {
968
1084
  : blob;
969
1085
  }
970
1086
 
1087
+ function encodeCallEnvelopePlaintext(envelope: SignedCallEnvelope): Uint8Array {
1088
+ return XUtils.decodeUTF8(CALL_ENVELOPE_PREFIX + JSON.stringify(envelope));
1089
+ }
1090
+
971
1091
  function encodeMessagePlaintext(
972
1092
  message: string,
973
1093
  opts?: MessageSendOptions,
@@ -983,6 +1103,13 @@ function encodeMessagePlaintext(
983
1103
  return formatVexRetentionEnvelope(body, opts?.retentionHintDays);
984
1104
  }
985
1105
 
1106
+ function jsonWireValue(value: unknown): unknown {
1107
+ // Match the payload JSON.stringify will send, including RTC toJSON() output.
1108
+ const encoded = JSON.stringify(value);
1109
+ const parsed: unknown = JSON.parse(encoded);
1110
+ return parsed;
1111
+ }
1112
+
986
1113
  function messageFromDecodedPlaintext(
987
1114
  decoded: DecodedMessagePlaintext,
988
1115
  ): Pick<Message, "extra" | "message" | "retentionHintDays"> {
@@ -995,6 +1122,12 @@ function messageFromDecodedPlaintext(
995
1122
  };
996
1123
  }
997
1124
 
1125
+ function normalizeCallEnvelopeBodyForWire(
1126
+ body: CallEnvelopeBody,
1127
+ ): CallEnvelopeBody {
1128
+ return CallEnvelopeBodySchema.parse(jsonWireValue(body));
1129
+ }
1130
+
998
1131
  function normalizeForwardedMessage(message: Message): Message {
999
1132
  const decoded = decodeMessagePlaintext(message.message);
1000
1133
  return {
@@ -1034,6 +1167,10 @@ const retryRequestNotifyData = z.union([
1034
1167
  * and {@link Client.once}.
1035
1168
  */
1036
1169
  export interface ClientEvents {
1170
+ /** Voice-call signaling changed or an incoming call was received. */
1171
+ call: (event: CallEvent) => void;
1172
+ /** Native/mobile call wake hint arrived; clients should sync call mail. */
1173
+ callWake: (wake: CallWakeNotifyData) => void;
1037
1174
  /** The client has been shut down (via {@link Client.close}). */
1038
1175
  closed: () => void;
1039
1176
  /** WebSocket authorized by the server; pre-auth setup begins. */
@@ -1280,6 +1417,31 @@ export class Client {
1280
1417
 
1281
1418
  private static readonly NOT_FOUND_TTL = 30 * 60 * 1000;
1282
1419
 
1420
+ /**
1421
+ * Voice-call signaling operations.
1422
+ *
1423
+ * Platform apps own native media capture/WebRTC. These methods only move
1424
+ * authenticated signaling and call state over Spire.
1425
+ */
1426
+ public calls: Calls = {
1427
+ accept: (callID: string, signal?: CallSignalPayload) =>
1428
+ this.sendEncryptedCallAction("accept", callID, signal),
1429
+ active: this.fetchActiveCalls.bind(this),
1430
+ cancel: (callID: string) =>
1431
+ this.sendEncryptedCallAction("cancel", callID),
1432
+ hangup: (callID: string) =>
1433
+ this.sendEncryptedCallAction("hangup", callID),
1434
+ ice: (callID: string, signal: CallSignalPayload) =>
1435
+ this.sendEncryptedCallAction("ice", callID, signal),
1436
+ iceServers: this.fetchIceServers.bind(this),
1437
+ reject: (callID: string) =>
1438
+ this.sendEncryptedCallAction("reject", callID),
1439
+ signal: (callID: string, signal: CallSignalPayload) =>
1440
+ this.sendEncryptedCallAction("signal", callID, signal),
1441
+ startDM: (recipientUserID: string, signal?: CallSignalPayload) =>
1442
+ this.startEncryptedDmCall(recipientUserID, signal),
1443
+ };
1444
+
1283
1445
  /**
1284
1446
  * Browser-safe NODE_ENV accessor.
1285
1447
  * Uses indirect lookup so the bare `process` global never appears in
@@ -1591,6 +1753,8 @@ export class Client {
1591
1753
 
1592
1754
  private autoReconnectEnabled = false;
1593
1755
 
1756
+ private readonly callStates = new Map<string, EncryptedCallState>();
1757
+
1594
1758
  private readonly cryptoProfile: CryptoProfile;
1595
1759
 
1596
1760
  private readonly database: Storage;
@@ -1600,13 +1764,12 @@ export class Client {
1600
1764
  private readonly decryptFailureCounts = new Map<string, number>();
1601
1765
 
1602
1766
  private device?: Device;
1603
-
1604
1767
  private deviceRecords: Record<string, Device> = {};
1768
+
1605
1769
  // ── Event subscription (composition over inheritance) ───────────────
1606
1770
  private readonly emitter = new EventEmitter<ClientEvents>();
1607
1771
 
1608
1772
  private fetchingMail: boolean = false;
1609
-
1610
1773
  private firstMailFetch = true;
1611
1774
  private readonly forwarded = new Set<string>();
1612
1775
  private readonly host: string;
@@ -1614,8 +1777,8 @@ export class Client {
1614
1777
  /** Cancels in-flight HTTP work on `close()` so `postAuth`/`getMail` cannot hang forever. */
1615
1778
  private readonly httpAbortController = new AbortController();
1616
1779
  private readonly idKeys: KeyPair | null;
1617
- private isAlive: boolean = true;
1618
1780
 
1781
+ private isAlive: boolean = true;
1619
1782
  private localMessageRetentionDays: number;
1620
1783
  private localRetentionPurgeTimer: null | ReturnType<typeof setInterval> =
1621
1784
  null;
@@ -2503,6 +2666,92 @@ export class Client {
2503
2666
  this.acknowledgeInboundMail(mail);
2504
2667
  }
2505
2668
 
2669
+ private applyCallEnvelopeBody(body: CallEnvelopeBody): CallEvent {
2670
+ const localUserID = this.getUser().userID;
2671
+ const peerUserID =
2672
+ body.fromUserID === localUserID ? body.toUserID : body.fromUserID;
2673
+ const peerDeviceID =
2674
+ body.fromUserID === localUserID
2675
+ ? body.toDeviceID
2676
+ : body.fromDeviceID;
2677
+ let state = this.callStates.get(body.callID);
2678
+ if (!state) {
2679
+ state = {
2680
+ peerDeviceID,
2681
+ peerUserID,
2682
+ pendingPeerDevices: [],
2683
+ sequence: 0,
2684
+ session: this.sessionFromCallEnvelope(body),
2685
+ };
2686
+ }
2687
+
2688
+ state.peerUserID = peerUserID;
2689
+ if (body.fromUserID !== localUserID || body.action === "accept") {
2690
+ state.peerDeviceID = peerDeviceID;
2691
+ }
2692
+ state.sequence = Math.max(state.sequence, body.sequence);
2693
+ state.session.expiresAt = body.expiresAt;
2694
+
2695
+ const now = new Date().toISOString();
2696
+ switch (body.action) {
2697
+ case "accept":
2698
+ state.session.status = "active";
2699
+ this.upsertCallParticipant(state.session, {
2700
+ acceptedAt: now,
2701
+ deviceID: body.fromDeviceID,
2702
+ joinedAt: now,
2703
+ state: "accepted",
2704
+ userID: body.fromUserID,
2705
+ });
2706
+ break;
2707
+ case "cancel":
2708
+ case "end":
2709
+ case "hangup":
2710
+ case "reject":
2711
+ case "timeout":
2712
+ state.session.status = "ended";
2713
+ state.session.endedAt = now;
2714
+ this.upsertCallParticipant(state.session, {
2715
+ leftAt: now,
2716
+ state: body.action === "reject" ? "rejected" : "left",
2717
+ userID: body.fromUserID,
2718
+ });
2719
+ break;
2720
+ case "ice":
2721
+ case "signal":
2722
+ break;
2723
+ case "invite":
2724
+ state.session.status = "ringing";
2725
+ this.upsertCallParticipant(state.session, {
2726
+ acceptedAt: body.createdAt,
2727
+ deviceID: body.createdByDeviceID,
2728
+ joinedAt: body.createdAt,
2729
+ state: "accepted",
2730
+ userID: body.createdBy,
2731
+ });
2732
+ this.upsertCallParticipant(state.session, {
2733
+ state: "ringing",
2734
+ userID: body.toUserID,
2735
+ });
2736
+ break;
2737
+ }
2738
+
2739
+ const event: CallEvent = {
2740
+ action: body.action,
2741
+ call: cloneCallSession(state.session),
2742
+ fromDeviceID: body.fromDeviceID,
2743
+ fromUserID: body.fromUserID,
2744
+ ...(body.signal ? { signal: body.signal } : {}),
2745
+ };
2746
+
2747
+ if (state.session.status === "ended") {
2748
+ this.callStates.delete(body.callID);
2749
+ } else {
2750
+ this.callStates.set(body.callID, state);
2751
+ }
2752
+ return event;
2753
+ }
2754
+
2506
2755
  private async approveDeviceRequest(requestID: string): Promise<Device> {
2507
2756
  const req = await this.getDeviceRegistrationRequest(requestID);
2508
2757
  if (!req) {
@@ -2584,6 +2833,50 @@ export class Client {
2584
2833
  return decodeHttpResponse(PasskeyOptionsCodec, response.data);
2585
2834
  }
2586
2835
 
2836
+ private async callEnvelopeForBody(
2837
+ body: CallEnvelopeBody,
2838
+ ): Promise<SignedCallEnvelope> {
2839
+ const wireBody = normalizeCallEnvelopeBodyForWire(body);
2840
+ const signed = await xSignAsync(
2841
+ canonicalJsonBytes(wireBody),
2842
+ this.signKeys.secretKey,
2843
+ );
2844
+ return {
2845
+ body: wireBody,
2846
+ signed: XUtils.encodeHex(signed),
2847
+ };
2848
+ }
2849
+
2850
+ private async callTargetsForState(
2851
+ state: EncryptedCallState,
2852
+ ): Promise<Device[]> {
2853
+ if (state.peerDeviceID) {
2854
+ const cached = this.deviceRecords[state.peerDeviceID];
2855
+ const device =
2856
+ cached ?? (await this.getDeviceByID(state.peerDeviceID));
2857
+ if (!device) {
2858
+ throw new Error(
2859
+ `Call peer device not found: ${state.peerDeviceID}`,
2860
+ );
2861
+ }
2862
+ return [device];
2863
+ }
2864
+ return state.pendingPeerDevices;
2865
+ }
2866
+
2867
+ private callWakeForEnvelope(
2868
+ body: CallEnvelopeBody,
2869
+ ): MailNotificationHint | undefined {
2870
+ if (body.action !== "invite") {
2871
+ return undefined;
2872
+ }
2873
+ return {
2874
+ callID: body.callID,
2875
+ event: "callWake",
2876
+ expiresAt: body.expiresAt,
2877
+ };
2878
+ }
2879
+
2587
2880
  private censorPreKey(preKey: PreKeysSQL): PreKeysWS {
2588
2881
  if (!preKey.index) {
2589
2882
  throw new Error("Key index is required.");
@@ -2742,6 +3035,7 @@ export class Client {
2742
3035
  * errors should not reject the full read pipeline.
2743
3036
  */
2744
3037
  allowKeyBundleFailure = false,
3038
+ notify?: MailNotificationHint,
2745
3039
  ): Promise<Message | null> {
2746
3040
  return this.runWithThisCryptoProfile(async () => {
2747
3041
  let keyBundle: KeyBundle;
@@ -2859,12 +3153,13 @@ export class Client {
2859
3153
  recipient: device.deviceID,
2860
3154
  sender: this.getDevice().deviceID,
2861
3155
  };
3156
+ const wireMail: MailWS = notify ? { ...mail, notify } : mail;
2862
3157
 
2863
3158
  const hmac = xHMAC(mail, SK);
2864
3159
 
2865
3160
  const msg: ResourceMsg = {
2866
3161
  action: "CREATE",
2867
- data: mail,
3162
+ data: wireMail,
2868
3163
  resourceType: "mail",
2869
3164
  transmissionID: uuid.v4(),
2870
3165
  type: "resource",
@@ -2888,35 +3183,41 @@ export class Client {
2888
3183
 
2889
3184
  this.emitter.emit("session", sessionEntry, user);
2890
3185
 
2891
- // emit the message
3186
+ const rawPlaintext = forward ? "" : XUtils.encodeUTF8(message);
3187
+ const callEnvelope = forward
3188
+ ? null
3189
+ : decodeCallEnvelopePlaintext(rawPlaintext);
2892
3190
  const forwardedMsg = forward
2893
3191
  ? messageSchema.parse(msgpack.decode(message))
2894
3192
  : null;
2895
- const shouldEmitHandshakeMessage = forward || message.length > 0;
2896
- const emitMsg: Message = forwardedMsg
3193
+ const emitMsg: Message | null = forwardedMsg
2897
3194
  ? { ...normalizeForwardedMessage(forwardedMsg), forward: true }
2898
- : {
2899
- authorID: mail.authorID,
2900
- decrypted: true,
2901
- direction: "outgoing",
2902
- forward: mail.forward,
2903
- group: mail.group ? uuid.stringify(mail.group) : null,
2904
- mailID: mail.mailID,
2905
- ...messageFromDecodedPlaintext(
2906
- decodeMessagePlaintext(XUtils.encodeUTF8(message)),
2907
- ),
2908
- nonce: XUtils.encodeHex(new Uint8Array(mail.nonce)),
2909
- readerID: mail.readerID,
2910
- recipient: mail.recipient,
2911
- sender: mail.sender,
2912
- timestamp: new Date().toISOString(),
2913
- };
2914
- if (shouldEmitHandshakeMessage) {
3195
+ : callEnvelope
3196
+ ? null
3197
+ : message.length > 0
3198
+ ? {
3199
+ authorID: mail.authorID,
3200
+ decrypted: true,
3201
+ direction: "outgoing",
3202
+ forward: mail.forward,
3203
+ group: mail.group ? uuid.stringify(mail.group) : null,
3204
+ mailID: mail.mailID,
3205
+ ...messageFromDecodedPlaintext(
3206
+ decodeMessagePlaintext(rawPlaintext),
3207
+ ),
3208
+ nonce: XUtils.encodeHex(new Uint8Array(mail.nonce)),
3209
+ readerID: mail.readerID,
3210
+ recipient: mail.recipient,
3211
+ sender: mail.sender,
3212
+ timestamp: new Date().toISOString(),
3213
+ }
3214
+ : null;
3215
+ if (emitMsg) {
2915
3216
  this.emitter.emit("message", emitMsg);
2916
3217
  }
2917
3218
 
2918
- await this.deliverMailResource(msg, hmac, mail);
2919
- return shouldEmitHandshakeMessage ? emitMsg : null;
3219
+ await this.deliverMailResource(msg, hmac, wireMail);
3220
+ return emitMsg;
2920
3221
  });
2921
3222
  }
2922
3223
 
@@ -2957,6 +3258,68 @@ export class Client {
2957
3258
  await this.http.delete(this.getHost() + "/server/" + serverID);
2958
3259
  }
2959
3260
 
3261
+ private async deliverCallEnvelopeBatch(args: {
3262
+ bodies: CallEnvelopeBody[];
3263
+ mailID: string;
3264
+ targetUser: User;
3265
+ }): Promise<void> {
3266
+ let failCount = 0;
3267
+ let lastErr: unknown;
3268
+ for (
3269
+ let index = 0;
3270
+ index < args.bodies.length;
3271
+ index += MAIL_FANOUT_CONCURRENCY
3272
+ ) {
3273
+ const batch = args.bodies.slice(
3274
+ index,
3275
+ index + MAIL_FANOUT_CONCURRENCY,
3276
+ );
3277
+ const results = await Promise.all(
3278
+ batch.map(async (body): Promise<undefined | unknown> => {
3279
+ try {
3280
+ const targetDevice =
3281
+ this.deviceRecords[body.toDeviceID] ??
3282
+ (await this.getDeviceByID(body.toDeviceID));
3283
+ if (!targetDevice) {
3284
+ throw new Error(
3285
+ `Call target device not found: ${body.toDeviceID}`,
3286
+ );
3287
+ }
3288
+ await this.sendCallEnvelopeMail({
3289
+ body,
3290
+ mailID: args.mailID,
3291
+ notify: this.callWakeForEnvelope(body),
3292
+ targetDevice,
3293
+ targetUser: args.targetUser,
3294
+ });
3295
+ return undefined;
3296
+ } catch (err: unknown) {
3297
+ return err;
3298
+ }
3299
+ }),
3300
+ );
3301
+ for (const result of results) {
3302
+ if (result !== undefined) {
3303
+ lastErr = result;
3304
+ failCount += 1;
3305
+ }
3306
+ }
3307
+ }
3308
+
3309
+ if (failCount > 0) {
3310
+ const base =
3311
+ lastErr instanceof Error ? lastErr : new Error(String(lastErr));
3312
+ if (failCount === args.bodies.length) {
3313
+ throw base;
3314
+ }
3315
+ const partial = new Error(
3316
+ `Call signaling failed to reach ${String(failCount)} of ` +
3317
+ `${String(args.bodies.length)} peer device(s).`,
3318
+ );
3319
+ partial.cause = base;
3320
+ throw partial;
3321
+ }
3322
+ }
2960
3323
  private deliverMailResource(
2961
3324
  msg: ResourceMsg,
2962
3325
  header: Uint8Array,
@@ -2980,6 +3343,7 @@ export class Client {
2980
3343
  }
2981
3344
  });
2982
3345
  }
3346
+
2983
3347
  private async deliverMailResourceOverSocket(
2984
3348
  msg: ResourceMsg,
2985
3349
  header: Uint8Array,
@@ -3009,6 +3373,7 @@ export class Client {
3009
3373
  });
3010
3374
  });
3011
3375
  }
3376
+
3012
3377
  private deviceListFailureDetail(err: unknown): string {
3013
3378
  if (!isHttpError(err)) {
3014
3379
  return "";
@@ -3040,6 +3405,70 @@ export class Client {
3040
3405
  });
3041
3406
  }
3042
3407
 
3408
+ private fetchActiveCalls(): Promise<CallSession[]> {
3409
+ const now = Date.now();
3410
+ const active: CallSession[] = [];
3411
+ for (const [callID, state] of this.callStates.entries()) {
3412
+ if (
3413
+ state.session.status === "ended" ||
3414
+ Date.parse(state.session.expiresAt) <= now
3415
+ ) {
3416
+ this.callStates.delete(callID);
3417
+ continue;
3418
+ }
3419
+ active.push(cloneCallSession(state.session));
3420
+ }
3421
+ return Promise.resolve(active);
3422
+ }
3423
+
3424
+ private async fetchCallPeer(args: {
3425
+ userID: string;
3426
+ }): Promise<{ devices: Device[]; user: User }> {
3427
+ const [user, err] = await this.fetchUser(args.userID);
3428
+ if (err) {
3429
+ throw err;
3430
+ }
3431
+ if (!user) {
3432
+ throw new Error("Call peer not found.");
3433
+ }
3434
+
3435
+ const afterBackoff = await this.fetchUserDeviceListWithBackoff(
3436
+ args.userID,
3437
+ "peer",
3438
+ );
3439
+ let deviceListRaw: Device[];
3440
+ try {
3441
+ const again = await this.fetchUserDeviceListOnce(args.userID);
3442
+ const byID = new Map<string, Device>();
3443
+ for (const device of afterBackoff) {
3444
+ byID.set(device.deviceID, device);
3445
+ }
3446
+ for (const device of again) {
3447
+ byID.set(device.deviceID, device);
3448
+ }
3449
+ deviceListRaw = [...byID.values()];
3450
+ } catch {
3451
+ deviceListRaw = afterBackoff;
3452
+ }
3453
+
3454
+ const devices = deviceListRaw
3455
+ .filter((device) => !device.deleted)
3456
+ .sort((a, b) => a.deviceID.localeCompare(b.deviceID, "en"));
3457
+ if (devices.length === 0) {
3458
+ throw new Error("Call peer has no active devices.");
3459
+ }
3460
+ return { devices, user };
3461
+ }
3462
+
3463
+ private async fetchIceServers(): Promise<IceServerConfig[]> {
3464
+ const res = await this.http.get(this.getHost() + "/calls/ice-servers", {
3465
+ responseType: "json",
3466
+ });
3467
+ return z
3468
+ .object({ iceServers: z.array(IceServerConfigSchema) })
3469
+ .parse(res.data).iceServers;
3470
+ }
3471
+
3043
3472
  /**
3044
3473
  * Gets a list of permissions for a server.
3045
3474
  *
@@ -3151,6 +3580,24 @@ export class Client {
3151
3580
  throw new Error(`${base}${this.deviceListFailureDetail(lastErr)}`);
3152
3581
  }
3153
3582
 
3583
+ private async fetchUserOrThrow(userID: string): Promise<User> {
3584
+ if (userID === this.getUser().userID) {
3585
+ return this.getUser();
3586
+ }
3587
+ const cached = this.userRecords[userID];
3588
+ if (cached) {
3589
+ return cached;
3590
+ }
3591
+ const [user, err] = await this.fetchUser(userID);
3592
+ if (err) {
3593
+ throw err;
3594
+ }
3595
+ if (!user) {
3596
+ throw new Error(`User not found: ${userID}`);
3597
+ }
3598
+ return user;
3599
+ }
3600
+
3154
3601
  /**
3155
3602
  * Finish a passkey login and adopt the resulting JWT as the
3156
3603
  * client's bearer token. After this call, `client.passkeys.*`
@@ -3680,6 +4127,22 @@ export class Client {
3680
4127
 
3681
4128
  private async handleNotify(msg: NotifyMsg) {
3682
4129
  switch (msg.event) {
4130
+ case "call":
4131
+ case "callInvite": {
4132
+ const parsed = CallEventSchema.safeParse(msg.data);
4133
+ if (parsed.success) {
4134
+ this.emitter.emit("call", parsed.data);
4135
+ }
4136
+ break;
4137
+ }
4138
+ case "callWake": {
4139
+ const parsed = callWakeNotifyData.safeParse(msg.data);
4140
+ await this.getMail();
4141
+ if (parsed.success) {
4142
+ this.emitter.emit("callWake", parsed.data);
4143
+ }
4144
+ break;
4145
+ }
3683
4146
  case "deviceRequest": {
3684
4147
  const parsed = deviceRequestNotifyData.safeParse(msg.data);
3685
4148
  if (parsed.success) {
@@ -3941,6 +4404,84 @@ export class Client {
3941
4404
  return decodeHttpResponse(PasskeyArrayCodec, response.data);
3942
4405
  }
3943
4406
 
4407
+ private makeCallEnvelopeBody(args: {
4408
+ action: CallAction;
4409
+ expiresAt: string;
4410
+ sequence: number;
4411
+ signal?: CallSignalPayload | undefined;
4412
+ state: EncryptedCallState;
4413
+ toDeviceID: string;
4414
+ toUserID: string;
4415
+ }): CallEnvelopeBody {
4416
+ return {
4417
+ action: args.action,
4418
+ callID: args.state.session.callID,
4419
+ conversationID: args.state.session.conversationID,
4420
+ conversationType: args.state.session.conversationType,
4421
+ createdAt: args.state.session.createdAt,
4422
+ createdBy: args.state.session.createdBy,
4423
+ createdByDeviceID: args.state.session.createdByDeviceID,
4424
+ expiresAt: args.expiresAt,
4425
+ fromDeviceID: this.getDevice().deviceID,
4426
+ fromUserID: this.getUser().userID,
4427
+ media: "audio",
4428
+ sequence: args.sequence,
4429
+ ...(args.signal ? { signal: args.signal } : {}),
4430
+ toDeviceID: args.toDeviceID,
4431
+ toUserID: args.toUserID,
4432
+ version: 1,
4433
+ };
4434
+ }
4435
+
4436
+ private markLocalCallAction(
4437
+ state: EncryptedCallState,
4438
+ action: CallAction,
4439
+ signal?: CallSignalPayload,
4440
+ ): CallEvent {
4441
+ const now = new Date().toISOString();
4442
+ if (action === "accept") {
4443
+ state.session.status = "active";
4444
+ state.session.expiresAt = new Date(
4445
+ Date.now() + CALL_MAX_TTL_MS,
4446
+ ).toISOString();
4447
+ this.upsertCallParticipant(state.session, {
4448
+ acceptedAt: now,
4449
+ deviceID: this.getDevice().deviceID,
4450
+ joinedAt: now,
4451
+ state: "accepted",
4452
+ userID: this.getUser().userID,
4453
+ });
4454
+ } else if (
4455
+ action === "cancel" ||
4456
+ action === "end" ||
4457
+ action === "hangup" ||
4458
+ action === "reject" ||
4459
+ action === "timeout"
4460
+ ) {
4461
+ state.session.status = "ended";
4462
+ state.session.endedAt = now;
4463
+ this.upsertCallParticipant(state.session, {
4464
+ leftAt: now,
4465
+ state: action === "reject" ? "rejected" : "left",
4466
+ userID: this.getUser().userID,
4467
+ });
4468
+ }
4469
+
4470
+ const event: CallEvent = {
4471
+ action,
4472
+ call: cloneCallSession(state.session),
4473
+ fromDeviceID: this.getDevice().deviceID,
4474
+ fromUserID: this.getUser().userID,
4475
+ ...(signal ? { signal } : {}),
4476
+ };
4477
+ if (state.session.status === "ended") {
4478
+ this.callStates.delete(state.session.callID);
4479
+ } else {
4480
+ this.callStates.set(state.session.callID, state);
4481
+ }
4482
+ return event;
4483
+ }
4484
+
3944
4485
  private async markSessionVerified(sessionID: string) {
3945
4486
  return this.database.markSessionVerified(sessionID);
3946
4487
  }
@@ -4169,6 +4710,41 @@ export class Client {
4169
4710
  }
4170
4711
  }
4171
4712
 
4713
+ private async processDecryptedCallEnvelope(args: {
4714
+ envelope: SignedCallEnvelope;
4715
+ mail: MailWS;
4716
+ }): Promise<CallEvent | null> {
4717
+ const body = args.envelope.body;
4718
+ if (
4719
+ body.fromDeviceID !== args.mail.sender ||
4720
+ body.fromUserID !== args.mail.authorID ||
4721
+ body.toDeviceID !== args.mail.recipient ||
4722
+ body.toUserID !== args.mail.readerID ||
4723
+ body.toDeviceID !== this.getDevice().deviceID ||
4724
+ body.toUserID !== this.getUser().userID
4725
+ ) {
4726
+ return null;
4727
+ }
4728
+
4729
+ const senderDevice = await this.getDeviceByID(body.fromDeviceID);
4730
+ if (!senderDevice || senderDevice.owner !== body.fromUserID) {
4731
+ return null;
4732
+ }
4733
+
4734
+ const opened = await xSignOpenAsync(
4735
+ XUtils.decodeHex(args.envelope.signed),
4736
+ XUtils.decodeHex(senderDevice.signKey),
4737
+ );
4738
+ if (!opened) {
4739
+ return null;
4740
+ }
4741
+ if (!XUtils.bytesEqual(opened, canonicalJsonBytes(body))) {
4742
+ return null;
4743
+ }
4744
+
4745
+ return this.applyCallEnvelopeBody(body);
4746
+ }
4747
+
4172
4748
  private async publishPendingDeviceRegistration(args: {
4173
4749
  challenge: string;
4174
4750
  requestID: string;
@@ -4524,46 +5100,61 @@ export class Client {
4524
5100
  if (!mail.forward) {
4525
5101
  plaintext = XUtils.encodeUTF8(unsealed);
4526
5102
  }
4527
- const decodedPlaintext = mail.forward
5103
+ const callEnvelope = mail.forward
4528
5104
  ? null
4529
- : decodeMessagePlaintext(plaintext);
4530
-
4531
- // emit the message
4532
- const fwdMsg1 = mail.forward
4533
- ? messageSchema.parse(msgpack.decode(unsealed))
4534
- : null;
4535
- const message: Message = fwdMsg1
4536
- ? {
4537
- ...normalizeForwardedMessage(fwdMsg1),
4538
- forward: true,
4539
- }
4540
- : {
4541
- authorID: mail.authorID,
4542
- decrypted: true,
4543
- direction: "incoming",
4544
- forward: mail.forward,
4545
- group: mail.group
4546
- ? uuid.stringify(mail.group)
4547
- : null,
4548
- mailID: mail.mailID,
4549
- ...messageFromDecodedPlaintext(
4550
- decodedPlaintext ?? {
4551
- message: plaintext,
4552
- },
4553
- ),
4554
- nonce: XUtils.encodeHex(
4555
- new Uint8Array(mail.nonce),
4556
- ),
4557
- readerID: mail.readerID,
4558
- recipient: mail.recipient,
4559
- sender: mail.sender,
4560
- timestamp: timestamp,
4561
- };
4562
-
4563
- const shouldEmitIncomingInitial =
4564
- mail.forward || plaintext.length > 0;
4565
- if (shouldEmitIncomingInitial) {
4566
- this.emitter.emit("message", message);
5105
+ : decodeCallEnvelopePlaintext(plaintext);
5106
+ if (callEnvelope) {
5107
+ const event =
5108
+ await this.processDecryptedCallEnvelope({
5109
+ envelope: callEnvelope,
5110
+ mail,
5111
+ });
5112
+ if (event) {
5113
+ this.emitter.emit("call", event);
5114
+ }
5115
+ } else {
5116
+ const decodedPlaintext = mail.forward
5117
+ ? null
5118
+ : decodeMessagePlaintext(plaintext);
5119
+
5120
+ const fwdMsg1 = mail.forward
5121
+ ? messageSchema.parse(
5122
+ msgpack.decode(unsealed),
5123
+ )
5124
+ : null;
5125
+ const message: Message = fwdMsg1
5126
+ ? {
5127
+ ...normalizeForwardedMessage(fwdMsg1),
5128
+ forward: true,
5129
+ }
5130
+ : {
5131
+ authorID: mail.authorID,
5132
+ decrypted: true,
5133
+ direction: "incoming",
5134
+ forward: mail.forward,
5135
+ group: mail.group
5136
+ ? uuid.stringify(mail.group)
5137
+ : null,
5138
+ mailID: mail.mailID,
5139
+ ...messageFromDecodedPlaintext(
5140
+ decodedPlaintext ?? {
5141
+ message: plaintext,
5142
+ },
5143
+ ),
5144
+ nonce: XUtils.encodeHex(
5145
+ new Uint8Array(mail.nonce),
5146
+ ),
5147
+ readerID: mail.readerID,
5148
+ recipient: mail.recipient,
5149
+ sender: mail.sender,
5150
+ timestamp: timestamp,
5151
+ };
5152
+
5153
+ const shouldEmitIncomingInitial =
5154
+ mail.forward || plaintext.length > 0;
5155
+ if (shouldEmitIncomingInitial) {
5156
+ this.emitter.emit("message", message);
5157
+ }
4567
5158
  }
4568
5159
  if (libvexDebugDmEnabled()) {
4569
5160
  try {
@@ -4823,37 +5414,51 @@ export class Client {
4823
5414
  ? messageSchema.parse(msgpack.decode(decrypted))
4824
5415
  : null;
4825
5416
  const rawIncoming = XUtils.encodeUTF8(decrypted);
4826
- const decodedPlaintext = mail.forward
5417
+ const callEnvelope = mail.forward
4827
5418
  ? null
4828
- : decodeMessagePlaintext(rawIncoming);
4829
- const message: Message = fwdMsg2
4830
- ? {
4831
- ...normalizeForwardedMessage(fwdMsg2),
4832
- forward: true,
4833
- }
4834
- : {
4835
- authorID: mail.authorID,
4836
- decrypted: true,
4837
- direction: "incoming",
4838
- forward: mail.forward,
4839
- group: mail.group
4840
- ? uuid.stringify(mail.group)
4841
- : null,
4842
- mailID: mail.mailID,
4843
- ...messageFromDecodedPlaintext(
4844
- decodedPlaintext ?? {
4845
- message: rawIncoming,
4846
- },
4847
- ),
4848
- nonce: XUtils.encodeHex(
4849
- new Uint8Array(mail.nonce),
4850
- ),
4851
- readerID: mail.readerID,
4852
- recipient: mail.recipient,
4853
- sender: mail.sender,
4854
- timestamp: timestamp,
4855
- };
4856
- this.emitter.emit("message", message);
5419
+ : decodeCallEnvelopePlaintext(rawIncoming);
5420
+ if (callEnvelope) {
5421
+ const event =
5422
+ await this.processDecryptedCallEnvelope({
5423
+ envelope: callEnvelope,
5424
+ mail,
5425
+ });
5426
+ if (event) {
5427
+ this.emitter.emit("call", event);
5428
+ }
5429
+ } else {
5430
+ const decodedPlaintext = mail.forward
5431
+ ? null
5432
+ : decodeMessagePlaintext(rawIncoming);
5433
+ const message: Message = fwdMsg2
5434
+ ? {
5435
+ ...normalizeForwardedMessage(fwdMsg2),
5436
+ forward: true,
5437
+ }
5438
+ : {
5439
+ authorID: mail.authorID,
5440
+ decrypted: true,
5441
+ direction: "incoming",
5442
+ forward: mail.forward,
5443
+ group: mail.group
5444
+ ? uuid.stringify(mail.group)
5445
+ : null,
5446
+ mailID: mail.mailID,
5447
+ ...messageFromDecodedPlaintext(
5448
+ decodedPlaintext ?? {
5449
+ message: rawIncoming,
5450
+ },
5451
+ ),
5452
+ nonce: XUtils.encodeHex(
5453
+ new Uint8Array(mail.nonce),
5454
+ ),
5455
+ readerID: mail.readerID,
5456
+ recipient: mail.recipient,
5457
+ sender: mail.sender,
5458
+ timestamp: timestamp,
5459
+ };
5460
+ this.emitter.emit("message", message);
5461
+ }
4857
5462
 
4858
5463
  const sqlPatch = sessionToSqlPatch(session);
4859
5464
  const persisted: SessionSQL = {
@@ -5269,6 +5874,73 @@ export class Client {
5269
5874
  }
5270
5875
  }
5271
5876
 
5877
+ private async sendCallEnvelopeMail(args: {
5878
+ body: CallEnvelopeBody;
5879
+ mailID: string;
5880
+ notify?: MailNotificationHint | undefined;
5881
+ targetDevice: Device;
5882
+ targetUser: User;
5883
+ }): Promise<void> {
5884
+ const envelope = await this.callEnvelopeForBody(args.body);
5885
+ await this.sendMailWithRecovery(
5886
+ args.targetDevice,
5887
+ args.targetUser,
5888
+ encodeCallEnvelopePlaintext(envelope),
5889
+ null,
5890
+ args.mailID,
5891
+ false,
5892
+ false,
5893
+ args.notify,
5894
+ );
5895
+ }
5896
+
5897
+ private async sendEncryptedCallAction(
5898
+ action: Exclude<CallAction, "end" | "invite" | "timeout">,
5899
+ callID: string,
5900
+ signal?: CallSignalPayload,
5901
+ ): Promise<CallEvent> {
5902
+ const state = this.callStates.get(callID);
5903
+ if (!state) {
5904
+ throw new Error("Unknown encrypted call: " + callID);
5905
+ }
5906
+
5907
+ const targets = await this.callTargetsForState(state);
5908
+ if (targets.length === 0) {
5909
+ throw new Error("Call has no reachable peer devices.");
5910
+ }
5911
+
5912
+ const targetUser = await this.fetchUserOrThrow(state.peerUserID);
5913
+ const sequence = state.sequence + 1;
5914
+ state.sequence = sequence;
5915
+ const expiresAt =
5916
+ action === "accept"
5917
+ ? new Date(Date.now() + CALL_MAX_TTL_MS).toISOString()
5918
+ : state.session.expiresAt;
5919
+ const bodies = targets.map((target) =>
5920
+ this.makeCallEnvelopeBody({
5921
+ action,
5922
+ expiresAt,
5923
+ sequence,
5924
+ signal,
5925
+ state,
5926
+ toDeviceID: target.deviceID,
5927
+ toUserID: target.owner,
5928
+ }),
5929
+ );
5930
+
5931
+ await this.deliverCallEnvelopeBatch({
5932
+ bodies,
5933
+ mailID: uuid.v4(),
5934
+ targetUser,
5935
+ });
5936
+
5937
+ if (targets.length === 1) {
5938
+ state.peerDeviceID = targets[0]?.deviceID;
5939
+ }
5940
+ state.session.expiresAt = expiresAt;
5941
+ return this.markLocalCallAction(state, action, signal);
5942
+ }
5943
+
5272
5944
  private async sendGroupMessage(
5273
5945
  channelID: string,
5274
5946
  message: string,
@@ -5405,6 +6077,7 @@ export class Client {
5405
6077
  mailID: null | string,
5406
6078
  forward: boolean,
5407
6079
  retry = false,
6080
+ notify?: MailNotificationHint,
5408
6081
  ): Promise<Message | null> {
5409
6082
  while (this.sending.has(device.deviceID)) {
5410
6083
  await sleep(100);
@@ -5431,6 +6104,7 @@ export class Client {
5431
6104
  mailID,
5432
6105
  forward,
5433
6106
  false,
6107
+ notify,
5434
6108
  );
5435
6109
  if (libvexDebugDmEnabled()) {
5436
6110
  debugLibvexDm("sendMail: createSession returned", {
@@ -5473,10 +6147,11 @@ export class Client {
5473
6147
  recipient: device.deviceID,
5474
6148
  sender: this.getDevice().deviceID,
5475
6149
  };
6150
+ const wireMail: MailWS = notify ? { ...mail, notify } : mail;
5476
6151
 
5477
6152
  const msgb: ResourceMsg = {
5478
6153
  action: "CREATE",
5479
- data: mail,
6154
+ data: wireMail,
5480
6155
  resourceType: "mail",
5481
6156
  transmissionID: uuid.v4(),
5482
6157
  type: "resource",
@@ -5484,28 +6159,36 @@ export class Client {
5484
6159
 
5485
6160
  const hmac = xHMAC(mail, messageKey);
5486
6161
 
6162
+ const rawPlaintext = forward ? "" : XUtils.encodeUTF8(msg);
6163
+ const callEnvelope = forward
6164
+ ? null
6165
+ : decodeCallEnvelopePlaintext(rawPlaintext);
5487
6166
  const fwdOut = forward
5488
6167
  ? messageSchema.parse(msgpack.decode(msg))
5489
6168
  : null;
5490
- const outMsg: Message = fwdOut
6169
+ const outMsg: Message | null = fwdOut
5491
6170
  ? { ...normalizeForwardedMessage(fwdOut), forward: true }
5492
- : {
5493
- authorID: mail.authorID,
5494
- decrypted: true,
5495
- direction: "outgoing",
5496
- forward: mail.forward,
5497
- group: mail.group ? uuid.stringify(mail.group) : null,
5498
- mailID: mail.mailID,
5499
- ...messageFromDecodedPlaintext(
5500
- decodeMessagePlaintext(XUtils.encodeUTF8(msg)),
5501
- ),
5502
- nonce: XUtils.encodeHex(new Uint8Array(mail.nonce)),
5503
- readerID: mail.readerID,
5504
- recipient: mail.recipient,
5505
- sender: mail.sender,
5506
- timestamp: new Date().toISOString(),
5507
- };
5508
- this.emitter.emit("message", outMsg);
6171
+ : callEnvelope
6172
+ ? null
6173
+ : {
6174
+ authorID: mail.authorID,
6175
+ decrypted: true,
6176
+ direction: "outgoing",
6177
+ forward: mail.forward,
6178
+ group: mail.group ? uuid.stringify(mail.group) : null,
6179
+ mailID: mail.mailID,
6180
+ ...messageFromDecodedPlaintext(
6181
+ decodeMessagePlaintext(rawPlaintext),
6182
+ ),
6183
+ nonce: XUtils.encodeHex(new Uint8Array(mail.nonce)),
6184
+ readerID: mail.readerID,
6185
+ recipient: mail.recipient,
6186
+ sender: mail.sender,
6187
+ timestamp: new Date().toISOString(),
6188
+ };
6189
+ if (outMsg) {
6190
+ this.emitter.emit("message", outMsg);
6191
+ }
5509
6192
 
5510
6193
  const sqlPatch = sessionToSqlPatch(session);
5511
6194
  const persisted: SessionSQL = {
@@ -5532,7 +6215,7 @@ export class Client {
5532
6215
  await this.database.saveSession(persisted);
5533
6216
  this.sessionRecords[XUtils.encodeHex(session.publicKey)] = session;
5534
6217
 
5535
- await this.deliverMailResource(msgb, hmac, mail);
6218
+ await this.deliverMailResource(msgb, hmac, wireMail);
5536
6219
  return outMsg;
5537
6220
  } finally {
5538
6221
  this.sending.delete(device.deviceID);
@@ -5547,6 +6230,7 @@ export class Client {
5547
6230
  mailID: null | string,
5548
6231
  forward: boolean,
5549
6232
  forceFreshSession = false,
6233
+ notify?: MailNotificationHint,
5550
6234
  ): Promise<Message | null> {
5551
6235
  try {
5552
6236
  return await this.sendMail(
@@ -5557,6 +6241,7 @@ export class Client {
5557
6241
  mailID,
5558
6242
  forward,
5559
6243
  forceFreshSession,
6244
+ notify,
5560
6245
  );
5561
6246
  } catch (err: unknown) {
5562
6247
  if (!this.shouldRetryDeliveryWithFreshSession(err)) {
@@ -5570,6 +6255,7 @@ export class Client {
5570
6255
  mailID,
5571
6256
  forward,
5572
6257
  true,
6258
+ notify,
5573
6259
  );
5574
6260
  }
5575
6261
  }
@@ -5747,6 +6433,33 @@ export class Client {
5747
6433
  this.send(receipt).catch(ignoreSocketTeardown);
5748
6434
  }
5749
6435
 
6436
+ private sessionFromCallEnvelope(body: CallEnvelopeBody): CallSession {
6437
+ const session: CallSession = {
6438
+ callID: body.callID,
6439
+ conversationID: body.conversationID,
6440
+ conversationType: body.conversationType,
6441
+ createdAt: body.createdAt,
6442
+ createdBy: body.createdBy,
6443
+ createdByDeviceID: body.createdByDeviceID,
6444
+ expiresAt: body.expiresAt,
6445
+ media: "audio",
6446
+ participants: [],
6447
+ status: body.action === "invite" ? "ringing" : "active",
6448
+ };
6449
+ this.upsertCallParticipant(session, {
6450
+ acceptedAt: body.createdAt,
6451
+ deviceID: body.createdByDeviceID,
6452
+ joinedAt: body.createdAt,
6453
+ state: "accepted",
6454
+ userID: body.createdBy,
6455
+ });
6456
+ this.upsertCallParticipant(session, {
6457
+ state: "ringing",
6458
+ userID: body.conversationID,
6459
+ });
6460
+ return session;
6461
+ }
6462
+
5750
6463
  private setAlive(status: boolean) {
5751
6464
  this.isAlive = status;
5752
6465
  }
@@ -5793,6 +6506,76 @@ export class Client {
5793
6506
  );
5794
6507
  }
5795
6508
 
6509
+ private async startEncryptedDmCall(
6510
+ recipientUserID: string,
6511
+ signal?: CallSignalPayload,
6512
+ ): Promise<CallEvent> {
6513
+ const { devices, user } = await this.fetchCallPeer({
6514
+ userID: recipientUserID,
6515
+ });
6516
+ const now = new Date();
6517
+ const createdAt = now.toISOString();
6518
+ const expiresAt = new Date(
6519
+ now.getTime() + CALL_INVITE_TTL_MS,
6520
+ ).toISOString();
6521
+ const session: CallSession = {
6522
+ callID: uuid.v4(),
6523
+ conversationID: recipientUserID,
6524
+ conversationType: "dm",
6525
+ createdAt,
6526
+ createdBy: this.getUser().userID,
6527
+ createdByDeviceID: this.getDevice().deviceID,
6528
+ expiresAt,
6529
+ media: "audio",
6530
+ participants: [
6531
+ {
6532
+ acceptedAt: createdAt,
6533
+ deviceID: this.getDevice().deviceID,
6534
+ joinedAt: createdAt,
6535
+ state: "accepted",
6536
+ userID: this.getUser().userID,
6537
+ },
6538
+ {
6539
+ state: "ringing",
6540
+ userID: recipientUserID,
6541
+ },
6542
+ ],
6543
+ status: "ringing",
6544
+ };
6545
+ const state: EncryptedCallState = {
6546
+ peerUserID: recipientUserID,
6547
+ pendingPeerDevices: devices,
6548
+ sequence: 1,
6549
+ session,
6550
+ };
6551
+ this.callStates.set(session.callID, state);
6552
+
6553
+ const bodies = devices.map((device) =>
6554
+ this.makeCallEnvelopeBody({
6555
+ action: "invite",
6556
+ expiresAt,
6557
+ sequence: state.sequence,
6558
+ signal,
6559
+ state,
6560
+ toDeviceID: device.deviceID,
6561
+ toUserID: recipientUserID,
6562
+ }),
6563
+ );
6564
+ await this.deliverCallEnvelopeBatch({
6565
+ bodies,
6566
+ mailID: uuid.v4(),
6567
+ targetUser: user,
6568
+ });
6569
+
6570
+ return {
6571
+ action: "invite",
6572
+ call: cloneCallSession(session),
6573
+ fromDeviceID: this.getDevice().deviceID,
6574
+ fromUserID: this.getUser().userID,
6575
+ ...(signal ? { signal } : {}),
6576
+ };
6577
+ }
6578
+
5796
6579
  private async submitOTK(amount: number) {
5797
6580
  const otks: UnsavedPreKey[] = [];
5798
6581
 
@@ -5926,4 +6709,18 @@ export class Client {
5926
6709
  return null;
5927
6710
  }
5928
6711
  }
6712
+
6713
+ private upsertCallParticipant(
6714
+ session: CallSession,
6715
+ patch: CallSession["participants"][number],
6716
+ ): void {
6717
+ const existing = session.participants.find(
6718
+ (participant) => participant.userID === patch.userID,
6719
+ );
6720
+ if (!existing) {
6721
+ session.participants.push({ ...patch });
6722
+ return;
6723
+ }
6724
+ Object.assign(existing, patch);
6725
+ }
5929
6726
  }