@vex-chat/libvex 7.2.0 → 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,8 +15,9 @@ import type {
15
15
  import type { KeyPair } from "@vex-chat/crypto";
16
16
  import type {
17
17
  ActionToken,
18
+ CallAction,
19
+ CallEnvelopeBody,
18
20
  CallEvent,
19
- CallResourceData,
20
21
  CallSession,
21
22
  CallSignalPayload,
22
23
  ChallMsg,
@@ -29,6 +30,7 @@ import type {
29
30
  IceServerConfig,
30
31
  Invite,
31
32
  KeyBundle,
33
+ MailNotificationHint,
32
34
  MailWS,
33
35
  NotifyMsg,
34
36
  Passkey,
@@ -42,6 +44,7 @@ import type {
42
44
  Server,
43
45
  ServerChannelBootstrap,
44
46
  SessionSQL,
47
+ SignedCallEnvelope,
45
48
  } from "@vex-chat/types";
46
49
  import type { ClientMessage } from "@vex-chat/types";
47
50
 
@@ -75,12 +78,13 @@ import {
75
78
  XUtils,
76
79
  } from "@vex-chat/crypto";
77
80
  import {
81
+ CallEnvelopeBodySchema,
78
82
  CallEventSchema,
79
- CallSessionSchema,
80
83
  IceServerConfigSchema,
81
84
  MailType,
82
85
  MailWSSchema,
83
86
  PermissionSchema,
87
+ SignedCallEnvelopeSchema,
84
88
  WSMessageSchema,
85
89
  } from "@vex-chat/types";
86
90
 
@@ -198,16 +202,6 @@ function debugLibvexDm(
198
202
  console.error(`[libvex:debug-dm] ${payload}`);
199
203
  }
200
204
 
201
- function errorFromUnknown(err: unknown): Error {
202
- if (err instanceof Error) {
203
- return err;
204
- }
205
- if (typeof err === "string") {
206
- return new Error(err);
207
- }
208
- return new Error(JSON.stringify(err));
209
- }
210
-
211
205
  function ignoreSocketTeardown(err: unknown): void {
212
206
  if (err instanceof WebSocketNotOpenError) return;
213
207
  // Re-throw anything else as a real unhandled rejection so it
@@ -730,7 +724,7 @@ export type { Server } from "@vex-chat/types";
730
724
  export type { ServerChannelBootstrap } from "@vex-chat/types";
731
725
 
732
726
  export interface NotificationSubscription {
733
- channel: "expo";
727
+ channel: NotificationSubscriptionChannel;
734
728
  createdAt: string;
735
729
  deviceID: string;
736
730
  enabled: boolean;
@@ -742,9 +736,17 @@ export interface NotificationSubscription {
742
736
  userID: string;
743
737
  }
744
738
 
739
+ export type NotificationSubscriptionChannel = "apnsVoip" | "expo" | "fcmCall";
740
+
741
+ const NotificationSubscriptionChannelSchema = z.enum([
742
+ "apnsVoip",
743
+ "expo",
744
+ "fcmCall",
745
+ ]);
746
+
745
747
  const NotificationSubscriptionSchema: z.ZodType<NotificationSubscription> =
746
748
  z.object({
747
- channel: z.literal("expo"),
749
+ channel: NotificationSubscriptionChannelSchema,
748
750
  createdAt: z.string(),
749
751
  deviceID: z.string(),
750
752
  enabled: z.boolean(),
@@ -757,7 +759,7 @@ const NotificationSubscriptionSchema: z.ZodType<NotificationSubscription> =
757
759
  });
758
760
 
759
761
  export interface NotificationSubscriptionInput {
760
- channel: "expo";
762
+ channel: NotificationSubscriptionChannel;
761
763
  events?: string[];
762
764
  platform?: "android" | "ios" | "web";
763
765
  token: string;
@@ -939,17 +941,35 @@ const messageSchema: z.ZodType<Message> = z.object({
939
941
  timestamp: z.string(),
940
942
  });
941
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;
942
947
  const MESSAGE_BLOB_PREFIX = "vex-message:1\n";
943
948
  const MAIL_FANOUT_CONCURRENCY = 8;
944
949
  const MAIL_BATCH_MAX_SIZE = 32;
945
950
  const MAIL_BATCH_FLUSH_DELAY_MS = 8;
946
951
 
952
+ interface CallWakeNotifyData {
953
+ callID: string;
954
+ expiresAt?: string | undefined;
955
+ mailID?: string | undefined;
956
+ mailNonce?: string | undefined;
957
+ }
958
+
947
959
  interface DecodedMessagePlaintext {
948
960
  extra?: null | string | undefined;
949
961
  message: string;
950
962
  retentionHintDays?: number | undefined;
951
963
  }
952
964
 
965
+ interface EncryptedCallState {
966
+ peerDeviceID?: string | undefined;
967
+ peerUserID: string;
968
+ pendingPeerDevices: Device[];
969
+ sequence: number;
970
+ session: CallSession;
971
+ }
972
+
953
973
  interface PendingMailBatchDelivery {
954
974
  header: Uint8Array;
955
975
  mail: MailWS;
@@ -971,6 +991,62 @@ const mailBatchResponseSchema = z.object({
971
991
  ),
972
992
  });
973
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
+
974
1050
  function decodeMessageBlob(body: string): DecodedMessagePlaintext {
975
1051
  if (!body.startsWith(MESSAGE_BLOB_PREFIX)) {
976
1052
  return { message: body };
@@ -1008,6 +1084,10 @@ function decodeMessagePlaintext(plaintext: string): DecodedMessagePlaintext {
1008
1084
  : blob;
1009
1085
  }
1010
1086
 
1087
+ function encodeCallEnvelopePlaintext(envelope: SignedCallEnvelope): Uint8Array {
1088
+ return XUtils.decodeUTF8(CALL_ENVELOPE_PREFIX + JSON.stringify(envelope));
1089
+ }
1090
+
1011
1091
  function encodeMessagePlaintext(
1012
1092
  message: string,
1013
1093
  opts?: MessageSendOptions,
@@ -1023,6 +1103,13 @@ function encodeMessagePlaintext(
1023
1103
  return formatVexRetentionEnvelope(body, opts?.retentionHintDays);
1024
1104
  }
1025
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
+
1026
1113
  function messageFromDecodedPlaintext(
1027
1114
  decoded: DecodedMessagePlaintext,
1028
1115
  ): Pick<Message, "extra" | "message" | "retentionHintDays"> {
@@ -1035,6 +1122,12 @@ function messageFromDecodedPlaintext(
1035
1122
  };
1036
1123
  }
1037
1124
 
1125
+ function normalizeCallEnvelopeBodyForWire(
1126
+ body: CallEnvelopeBody,
1127
+ ): CallEnvelopeBody {
1128
+ return CallEnvelopeBodySchema.parse(jsonWireValue(body));
1129
+ }
1130
+
1038
1131
  function normalizeForwardedMessage(message: Message): Message {
1039
1132
  const decoded = decodeMessagePlaintext(message.message);
1040
1133
  return {
@@ -1076,6 +1169,8 @@ const retryRequestNotifyData = z.union([
1076
1169
  export interface ClientEvents {
1077
1170
  /** Voice-call signaling changed or an incoming call was received. */
1078
1171
  call: (event: CallEvent) => void;
1172
+ /** Native/mobile call wake hint arrived; clients should sync call mail. */
1173
+ callWake: (wake: CallWakeNotifyData) => void;
1079
1174
  /** The client has been shut down (via {@link Client.close}). */
1080
1175
  closed: () => void;
1081
1176
  /** WebSocket authorized by the server; pre-auth setup begins. */
@@ -1330,25 +1425,21 @@ export class Client {
1330
1425
  */
1331
1426
  public calls: Calls = {
1332
1427
  accept: (callID: string, signal?: CallSignalPayload) =>
1333
- this.sendCallResource("ACCEPT", {
1334
- callID,
1335
- ...(signal ? { signal } : {}),
1336
- }),
1428
+ this.sendEncryptedCallAction("accept", callID, signal),
1337
1429
  active: this.fetchActiveCalls.bind(this),
1338
- cancel: (callID: string) => this.sendCallResource("CANCEL", { callID }),
1339
- hangup: (callID: string) => this.sendCallResource("HANGUP", { callID }),
1430
+ cancel: (callID: string) =>
1431
+ this.sendEncryptedCallAction("cancel", callID),
1432
+ hangup: (callID: string) =>
1433
+ this.sendEncryptedCallAction("hangup", callID),
1340
1434
  ice: (callID: string, signal: CallSignalPayload) =>
1341
- this.sendCallResource("ICE", { callID, signal }),
1435
+ this.sendEncryptedCallAction("ice", callID, signal),
1342
1436
  iceServers: this.fetchIceServers.bind(this),
1343
- reject: (callID: string) => this.sendCallResource("REJECT", { callID }),
1437
+ reject: (callID: string) =>
1438
+ this.sendEncryptedCallAction("reject", callID),
1344
1439
  signal: (callID: string, signal: CallSignalPayload) =>
1345
- this.sendCallResource("SIGNAL", { callID, signal }),
1440
+ this.sendEncryptedCallAction("signal", callID, signal),
1346
1441
  startDM: (recipientUserID: string, signal?: CallSignalPayload) =>
1347
- this.sendCallResource("INVITE", {
1348
- conversationType: "dm",
1349
- recipientUserID,
1350
- ...(signal ? { signal } : {}),
1351
- }),
1442
+ this.startEncryptedDmCall(recipientUserID, signal),
1352
1443
  };
1353
1444
 
1354
1445
  /**
@@ -1662,6 +1753,8 @@ export class Client {
1662
1753
 
1663
1754
  private autoReconnectEnabled = false;
1664
1755
 
1756
+ private readonly callStates = new Map<string, EncryptedCallState>();
1757
+
1665
1758
  private readonly cryptoProfile: CryptoProfile;
1666
1759
 
1667
1760
  private readonly database: Storage;
@@ -1671,13 +1764,12 @@ export class Client {
1671
1764
  private readonly decryptFailureCounts = new Map<string, number>();
1672
1765
 
1673
1766
  private device?: Device;
1674
-
1675
1767
  private deviceRecords: Record<string, Device> = {};
1768
+
1676
1769
  // ── Event subscription (composition over inheritance) ───────────────
1677
1770
  private readonly emitter = new EventEmitter<ClientEvents>();
1678
1771
 
1679
1772
  private fetchingMail: boolean = false;
1680
-
1681
1773
  private firstMailFetch = true;
1682
1774
  private readonly forwarded = new Set<string>();
1683
1775
  private readonly host: string;
@@ -1685,8 +1777,8 @@ export class Client {
1685
1777
  /** Cancels in-flight HTTP work on `close()` so `postAuth`/`getMail` cannot hang forever. */
1686
1778
  private readonly httpAbortController = new AbortController();
1687
1779
  private readonly idKeys: KeyPair | null;
1688
- private isAlive: boolean = true;
1689
1780
 
1781
+ private isAlive: boolean = true;
1690
1782
  private localMessageRetentionDays: number;
1691
1783
  private localRetentionPurgeTimer: null | ReturnType<typeof setInterval> =
1692
1784
  null;
@@ -2574,6 +2666,92 @@ export class Client {
2574
2666
  this.acknowledgeInboundMail(mail);
2575
2667
  }
2576
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
+
2577
2755
  private async approveDeviceRequest(requestID: string): Promise<Device> {
2578
2756
  const req = await this.getDeviceRegistrationRequest(requestID);
2579
2757
  if (!req) {
@@ -2655,6 +2833,50 @@ export class Client {
2655
2833
  return decodeHttpResponse(PasskeyOptionsCodec, response.data);
2656
2834
  }
2657
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
+
2658
2880
  private censorPreKey(preKey: PreKeysSQL): PreKeysWS {
2659
2881
  if (!preKey.index) {
2660
2882
  throw new Error("Key index is required.");
@@ -2813,6 +3035,7 @@ export class Client {
2813
3035
  * errors should not reject the full read pipeline.
2814
3036
  */
2815
3037
  allowKeyBundleFailure = false,
3038
+ notify?: MailNotificationHint,
2816
3039
  ): Promise<Message | null> {
2817
3040
  return this.runWithThisCryptoProfile(async () => {
2818
3041
  let keyBundle: KeyBundle;
@@ -2930,12 +3153,13 @@ export class Client {
2930
3153
  recipient: device.deviceID,
2931
3154
  sender: this.getDevice().deviceID,
2932
3155
  };
3156
+ const wireMail: MailWS = notify ? { ...mail, notify } : mail;
2933
3157
 
2934
3158
  const hmac = xHMAC(mail, SK);
2935
3159
 
2936
3160
  const msg: ResourceMsg = {
2937
3161
  action: "CREATE",
2938
- data: mail,
3162
+ data: wireMail,
2939
3163
  resourceType: "mail",
2940
3164
  transmissionID: uuid.v4(),
2941
3165
  type: "resource",
@@ -2959,35 +3183,41 @@ export class Client {
2959
3183
 
2960
3184
  this.emitter.emit("session", sessionEntry, user);
2961
3185
 
2962
- // emit the message
3186
+ const rawPlaintext = forward ? "" : XUtils.encodeUTF8(message);
3187
+ const callEnvelope = forward
3188
+ ? null
3189
+ : decodeCallEnvelopePlaintext(rawPlaintext);
2963
3190
  const forwardedMsg = forward
2964
3191
  ? messageSchema.parse(msgpack.decode(message))
2965
3192
  : null;
2966
- const shouldEmitHandshakeMessage = forward || message.length > 0;
2967
- const emitMsg: Message = forwardedMsg
3193
+ const emitMsg: Message | null = forwardedMsg
2968
3194
  ? { ...normalizeForwardedMessage(forwardedMsg), forward: true }
2969
- : {
2970
- authorID: mail.authorID,
2971
- decrypted: true,
2972
- direction: "outgoing",
2973
- forward: mail.forward,
2974
- group: mail.group ? uuid.stringify(mail.group) : null,
2975
- mailID: mail.mailID,
2976
- ...messageFromDecodedPlaintext(
2977
- decodeMessagePlaintext(XUtils.encodeUTF8(message)),
2978
- ),
2979
- nonce: XUtils.encodeHex(new Uint8Array(mail.nonce)),
2980
- readerID: mail.readerID,
2981
- recipient: mail.recipient,
2982
- sender: mail.sender,
2983
- timestamp: new Date().toISOString(),
2984
- };
2985
- 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) {
2986
3216
  this.emitter.emit("message", emitMsg);
2987
3217
  }
2988
3218
 
2989
- await this.deliverMailResource(msg, hmac, mail);
2990
- return shouldEmitHandshakeMessage ? emitMsg : null;
3219
+ await this.deliverMailResource(msg, hmac, wireMail);
3220
+ return emitMsg;
2991
3221
  });
2992
3222
  }
2993
3223
 
@@ -3028,6 +3258,68 @@ export class Client {
3028
3258
  await this.http.delete(this.getHost() + "/server/" + serverID);
3029
3259
  }
3030
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
+ }
3031
3323
  private deliverMailResource(
3032
3324
  msg: ResourceMsg,
3033
3325
  header: Uint8Array,
@@ -3113,13 +3405,61 @@ export class Client {
3113
3405
  });
3114
3406
  }
3115
3407
 
3116
- private async fetchActiveCalls(): Promise<CallSession[]> {
3117
- const res = await this.http.get(this.getHost() + "/calls/active", {
3118
- responseType: "json",
3119
- });
3120
- return z.object({ calls: z.array(CallSessionSchema) }).parse(res.data)
3121
- .calls;
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);
3122
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
+
3123
3463
  private async fetchIceServers(): Promise<IceServerConfig[]> {
3124
3464
  const res = await this.http.get(this.getHost() + "/calls/ice-servers", {
3125
3465
  responseType: "json",
@@ -3240,6 +3580,24 @@ export class Client {
3240
3580
  throw new Error(`${base}${this.deviceListFailureDetail(lastErr)}`);
3241
3581
  }
3242
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
+
3243
3601
  /**
3244
3602
  * Finish a passkey login and adopt the resulting JWT as the
3245
3603
  * client's bearer token. After this call, `client.passkeys.*`
@@ -3777,6 +4135,14 @@ export class Client {
3777
4135
  }
3778
4136
  break;
3779
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
+ }
3780
4146
  case "deviceRequest": {
3781
4147
  const parsed = deviceRequestNotifyData.safeParse(msg.data);
3782
4148
  if (parsed.success) {
@@ -4038,6 +4404,84 @@ export class Client {
4038
4404
  return decodeHttpResponse(PasskeyArrayCodec, response.data);
4039
4405
  }
4040
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
+
4041
4485
  private async markSessionVerified(sessionID: string) {
4042
4486
  return this.database.markSessionVerified(sessionID);
4043
4487
  }
@@ -4266,6 +4710,41 @@ export class Client {
4266
4710
  }
4267
4711
  }
4268
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
+
4269
4748
  private async publishPendingDeviceRegistration(args: {
4270
4749
  challenge: string;
4271
4750
  requestID: string;
@@ -4621,46 +5100,61 @@ export class Client {
4621
5100
  if (!mail.forward) {
4622
5101
  plaintext = XUtils.encodeUTF8(unsealed);
4623
5102
  }
4624
- const decodedPlaintext = mail.forward
5103
+ const callEnvelope = mail.forward
4625
5104
  ? null
4626
- : decodeMessagePlaintext(plaintext);
4627
-
4628
- // emit the message
4629
- const fwdMsg1 = mail.forward
4630
- ? messageSchema.parse(msgpack.decode(unsealed))
4631
- : null;
4632
- const message: Message = fwdMsg1
4633
- ? {
4634
- ...normalizeForwardedMessage(fwdMsg1),
4635
- forward: true,
4636
- }
4637
- : {
4638
- authorID: mail.authorID,
4639
- decrypted: true,
4640
- direction: "incoming",
4641
- forward: mail.forward,
4642
- group: mail.group
4643
- ? uuid.stringify(mail.group)
4644
- : null,
4645
- mailID: mail.mailID,
4646
- ...messageFromDecodedPlaintext(
4647
- decodedPlaintext ?? {
4648
- message: plaintext,
4649
- },
4650
- ),
4651
- nonce: XUtils.encodeHex(
4652
- new Uint8Array(mail.nonce),
4653
- ),
4654
- readerID: mail.readerID,
4655
- recipient: mail.recipient,
4656
- sender: mail.sender,
4657
- timestamp: timestamp,
4658
- };
4659
-
4660
- const shouldEmitIncomingInitial =
4661
- mail.forward || plaintext.length > 0;
4662
- if (shouldEmitIncomingInitial) {
4663
- 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
+ }
4664
5158
  }
4665
5159
  if (libvexDebugDmEnabled()) {
4666
5160
  try {
@@ -4920,37 +5414,51 @@ export class Client {
4920
5414
  ? messageSchema.parse(msgpack.decode(decrypted))
4921
5415
  : null;
4922
5416
  const rawIncoming = XUtils.encodeUTF8(decrypted);
4923
- const decodedPlaintext = mail.forward
5417
+ const callEnvelope = mail.forward
4924
5418
  ? null
4925
- : decodeMessagePlaintext(rawIncoming);
4926
- const message: Message = fwdMsg2
4927
- ? {
4928
- ...normalizeForwardedMessage(fwdMsg2),
4929
- forward: true,
4930
- }
4931
- : {
4932
- authorID: mail.authorID,
4933
- decrypted: true,
4934
- direction: "incoming",
4935
- forward: mail.forward,
4936
- group: mail.group
4937
- ? uuid.stringify(mail.group)
4938
- : null,
4939
- mailID: mail.mailID,
4940
- ...messageFromDecodedPlaintext(
4941
- decodedPlaintext ?? {
4942
- message: rawIncoming,
4943
- },
4944
- ),
4945
- nonce: XUtils.encodeHex(
4946
- new Uint8Array(mail.nonce),
4947
- ),
4948
- readerID: mail.readerID,
4949
- recipient: mail.recipient,
4950
- sender: mail.sender,
4951
- timestamp: timestamp,
4952
- };
4953
- 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
+ }
4954
5462
 
4955
5463
  const sqlPatch = sessionToSqlPatch(session);
4956
5464
  const persisted: SessionSQL = {
@@ -5366,87 +5874,71 @@ export class Client {
5366
5874
  }
5367
5875
  }
5368
5876
 
5369
- private async sendCallResource(
5370
- action: string,
5371
- data: CallResourceData,
5372
- ): Promise<CallEvent> {
5373
- const msg: ResourceMsg = {
5374
- action,
5375
- data,
5376
- resourceType: "call",
5377
- transmissionID: uuid.v4(),
5378
- type: "resource",
5379
- };
5380
-
5381
- return await new Promise<CallEvent>((resolve, reject) => {
5382
- const settle = (err: null | unknown, event?: CallEvent) => {
5383
- this.socket.off("message", callback);
5384
- if (err !== null) {
5385
- reject(errorFromUnknown(err));
5386
- return;
5387
- }
5388
- if (!event) {
5389
- reject(new Error("Call signaling response was empty."));
5390
- return;
5391
- }
5392
- resolve(event);
5393
- };
5394
-
5395
- const callback = (packedMsg: Uint8Array) => {
5396
- const [_header, receivedMsg] = XUtils.unpackMessage(packedMsg);
5397
- if (receivedMsg.transmissionID !== msg.transmissionID) {
5398
- return;
5399
- }
5400
-
5401
- const parsed = WSMessageSchema.safeParse(receivedMsg);
5402
- if (!parsed.success) {
5403
- settle(
5404
- "Call signaling failed: " + JSON.stringify(receivedMsg),
5405
- );
5406
- return;
5407
- }
5408
-
5409
- if (parsed.data.type === "success") {
5410
- const event = CallEventSchema.safeParse(parsed.data.data);
5411
- if (!event.success) {
5412
- settle(
5413
- "Invalid call signaling response: " +
5414
- JSON.stringify(event.error.issues),
5415
- );
5416
- return;
5417
- }
5418
- settle(null, event.data);
5419
- return;
5420
- }
5421
-
5422
- if (parsed.data.type === "error") {
5423
- settle(new Error(parsed.data.error));
5424
- return;
5425
- }
5426
-
5427
- if (
5428
- parsed.data.type === "notify" &&
5429
- (parsed.data.event === "call" ||
5430
- parsed.data.event === "callInvite")
5431
- ) {
5432
- const event = CallEventSchema.safeParse(parsed.data.data);
5433
- if (event.success) {
5434
- settle(null, event.data);
5435
- }
5436
- return;
5437
- }
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
+ }
5438
5896
 
5439
- settle(
5440
- "Unexpected call signaling response: " +
5441
- JSON.stringify(parsed.data),
5442
- );
5443
- };
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
+ );
5444
5930
 
5445
- this.socket.on("message", callback);
5446
- this.send(msg).catch((err: unknown) => {
5447
- settle(err);
5448
- });
5931
+ await this.deliverCallEnvelopeBatch({
5932
+ bodies,
5933
+ mailID: uuid.v4(),
5934
+ targetUser,
5449
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);
5450
5942
  }
5451
5943
 
5452
5944
  private async sendGroupMessage(
@@ -5585,6 +6077,7 @@ export class Client {
5585
6077
  mailID: null | string,
5586
6078
  forward: boolean,
5587
6079
  retry = false,
6080
+ notify?: MailNotificationHint,
5588
6081
  ): Promise<Message | null> {
5589
6082
  while (this.sending.has(device.deviceID)) {
5590
6083
  await sleep(100);
@@ -5611,6 +6104,7 @@ export class Client {
5611
6104
  mailID,
5612
6105
  forward,
5613
6106
  false,
6107
+ notify,
5614
6108
  );
5615
6109
  if (libvexDebugDmEnabled()) {
5616
6110
  debugLibvexDm("sendMail: createSession returned", {
@@ -5653,10 +6147,11 @@ export class Client {
5653
6147
  recipient: device.deviceID,
5654
6148
  sender: this.getDevice().deviceID,
5655
6149
  };
6150
+ const wireMail: MailWS = notify ? { ...mail, notify } : mail;
5656
6151
 
5657
6152
  const msgb: ResourceMsg = {
5658
6153
  action: "CREATE",
5659
- data: mail,
6154
+ data: wireMail,
5660
6155
  resourceType: "mail",
5661
6156
  transmissionID: uuid.v4(),
5662
6157
  type: "resource",
@@ -5664,28 +6159,36 @@ export class Client {
5664
6159
 
5665
6160
  const hmac = xHMAC(mail, messageKey);
5666
6161
 
6162
+ const rawPlaintext = forward ? "" : XUtils.encodeUTF8(msg);
6163
+ const callEnvelope = forward
6164
+ ? null
6165
+ : decodeCallEnvelopePlaintext(rawPlaintext);
5667
6166
  const fwdOut = forward
5668
6167
  ? messageSchema.parse(msgpack.decode(msg))
5669
6168
  : null;
5670
- const outMsg: Message = fwdOut
6169
+ const outMsg: Message | null = fwdOut
5671
6170
  ? { ...normalizeForwardedMessage(fwdOut), forward: true }
5672
- : {
5673
- authorID: mail.authorID,
5674
- decrypted: true,
5675
- direction: "outgoing",
5676
- forward: mail.forward,
5677
- group: mail.group ? uuid.stringify(mail.group) : null,
5678
- mailID: mail.mailID,
5679
- ...messageFromDecodedPlaintext(
5680
- decodeMessagePlaintext(XUtils.encodeUTF8(msg)),
5681
- ),
5682
- nonce: XUtils.encodeHex(new Uint8Array(mail.nonce)),
5683
- readerID: mail.readerID,
5684
- recipient: mail.recipient,
5685
- sender: mail.sender,
5686
- timestamp: new Date().toISOString(),
5687
- };
5688
- 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
+ }
5689
6192
 
5690
6193
  const sqlPatch = sessionToSqlPatch(session);
5691
6194
  const persisted: SessionSQL = {
@@ -5712,7 +6215,7 @@ export class Client {
5712
6215
  await this.database.saveSession(persisted);
5713
6216
  this.sessionRecords[XUtils.encodeHex(session.publicKey)] = session;
5714
6217
 
5715
- await this.deliverMailResource(msgb, hmac, mail);
6218
+ await this.deliverMailResource(msgb, hmac, wireMail);
5716
6219
  return outMsg;
5717
6220
  } finally {
5718
6221
  this.sending.delete(device.deviceID);
@@ -5727,6 +6230,7 @@ export class Client {
5727
6230
  mailID: null | string,
5728
6231
  forward: boolean,
5729
6232
  forceFreshSession = false,
6233
+ notify?: MailNotificationHint,
5730
6234
  ): Promise<Message | null> {
5731
6235
  try {
5732
6236
  return await this.sendMail(
@@ -5737,6 +6241,7 @@ export class Client {
5737
6241
  mailID,
5738
6242
  forward,
5739
6243
  forceFreshSession,
6244
+ notify,
5740
6245
  );
5741
6246
  } catch (err: unknown) {
5742
6247
  if (!this.shouldRetryDeliveryWithFreshSession(err)) {
@@ -5750,6 +6255,7 @@ export class Client {
5750
6255
  mailID,
5751
6256
  forward,
5752
6257
  true,
6258
+ notify,
5753
6259
  );
5754
6260
  }
5755
6261
  }
@@ -5927,6 +6433,33 @@ export class Client {
5927
6433
  this.send(receipt).catch(ignoreSocketTeardown);
5928
6434
  }
5929
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
+
5930
6463
  private setAlive(status: boolean) {
5931
6464
  this.isAlive = status;
5932
6465
  }
@@ -5973,6 +6506,76 @@ export class Client {
5973
6506
  );
5974
6507
  }
5975
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
+
5976
6579
  private async submitOTK(amount: number) {
5977
6580
  const otks: UnsavedPreKey[] = [];
5978
6581
 
@@ -6106,4 +6709,18 @@ export class Client {
6106
6709
  return null;
6107
6710
  }
6108
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
+ }
6109
6726
  }