import { CryptoService } from '../crypto/crypto-service'; import { SessionManager } from './session-manager'; import { BroadcastCallback, CallEventCallback, DropOnAirClient, EventCallback, CreateRoomOptions, GroupCallEventCallback, GroupInfo, GroupMessageCallback, GroupMessageDeleteCallback, GroupMessageEditCallback, Room, UpdateRoomOptions, SfuToken, SfuRecording, InitializeOptions, MessageCallback, MessageDeleteCallback, MessageEditCallback, TurnCredentials, DeviceInfo, ReadReceiptCallback, TypingCallback, SignalCallback, NotificationClearCallback, DraftSyncCallback, KeyCustody, PushPayload } from './types'; import { AttachmentRef, CreateUploadSessionOptions, DownloadedAttachment, PrepareAttachmentOptions, UploadSession } from '../attachment/attachment-types'; /** * A member has joined a conversation but has not published a messaging key yet. * * There is nothing to encrypt to them with, so they cannot be reached at all * until their key lands. Its own type because it is the one key failure worth * carrying on past: everybody else in the conversation can still be written to, * and failing the whole send punishes several people for one who is still * arriving. * * Usually brief. A device publishes its key moments after joining. */ export declare class NoKeyPublishedYet extends Error { readonly userId: string; constructor(userId: string); } export declare class MessagingClient implements DropOnAirClient { private readonly options; private readonly cryptoService; private readonly sessionManager; private readonly storage; private readonly wsUrl; private readonly httpUrl; private readonly tokenExchangeEndpoint; private readonly keyDirectoryEndpoint; private readonly defaultPushPayload?; private readonly fetchFn; private readonly codec; private attachmentClient; private transport; private resolvedTransportName; private shouldReconnect; private reconnectTimer; private dropOnAirJwt; private currentUserId; private deviceId; /** * The push token this device last offered, held until a connection exists and * re-sent on every connect. Cleared by `unregisterPushToken`. */ private pendingPushRegistration; private rateLimited; private reconnectAttempt; private connectGeneration; private handlingJwtExpiry; private proactiveRefreshTimer; private visibilityChangeHandler; private readonly autoAckIncomingMessages; private static readonly DEVICE_KEYS_CACHE_TTL_MS; /** * Resolves the payload for a send: what the caller passed, otherwise the * client default, otherwise none at all. Returns undefined when there is * nothing to send, so no push is requested rather than an empty one. */ private resolvePushPayload; /** * The same translation for a call invite, without the fallback. * * A call deliberately never inherits `defaultPushPayload`. That default says * what an arriving message should look like, and an app that set one years * ago has not asked for its calls to start reaching phones that are not * connected. Attaching a payload here is the whole of the opt-in, so it has * to be something the caller wrote on purpose. */ private callPushPayload; private readonly deviceKeysCache; private readonly callListeners; /** Pending startCall resolver, only one outgoing call can be in-flight at a time. */ private pendingInviteResolve; private pendingInviteReject; private readonly groupMessageListeners; private readonly groupCallListeners; /** Pending startGroupCall resolver. */ private pendingGroupInviteResolve; private pendingGroupInviteReject; /** Pending joinRoom resolver, keyed by roomId. */ private readonly pendingRoomJoins; private reconnectDelayMs; /** Schedule a single reconnect, replacing any pending one (never stack timers). */ private scheduleReconnect; /** * Register a document visibilitychange listener so that when the iOS app * returns from background we can immediately assess the JWT state. * * Background problem: iOS WebView suspends JavaScript (including all * setTimeout timers) while the app is backgrounded. A proactive-refresh * timer scheduled for t+810 s effectively stops ticking, so if the user * leaves the app open for 15+ minutes the JWT expires before the timer * fires. This listener re-examines the JWT as soon as the user brings the * app back to the foreground and either: * a) reschedules the timer with the corrected remaining time, or * b) triggers an immediate reconnect if the token has already expired or * will expire within the next 90 seconds. */ private registerVisibilityChangeHandler; private unregisterVisibilityChangeHandler; /** * Called every time the app returns to the foreground. * Decides whether to reconnect immediately or just reschedule the proactive * refresh timer with the time remaining from the current JWT's expiry. */ private onAppForeground; /** * Schedule a proactive token rotation 90 seconds before the JWT expires. * When it fires, a fresh token is pre-fetched and the current WebSocket is * closed cleanly so the reconnect loop picks up the new token immediately. * This prevents the server from ever rejecting a message due to an expired JWT. */ private scheduleProactiveTokenRefresh; private readonly messageListeners; private readonly eventListeners; private readonly broadcastListeners; private readonly messageEditListeners; private readonly readReceiptListeners; private readonly typingListeners; private readonly signalListeners; private readonly notificationClearListeners; private readonly draftSyncListeners; private readonly messageDeleteListeners; private readonly groupMessageEditListeners; private readonly groupMessageDeleteListeners; /** ------------------------------------------------------------------ * Lightweight structured logger. Only active when options.debug === true. * Fields are safe to log: no plaintext, no private keys, no full JWTs. * ------------------------------------------------------------------ */ private log; private logError; /** * Build the common headers required on every backend API call. * X-Platform and X-API-Version are forwarded when supplied in InitializeOptions. */ private backendHeaders; /** Redact a JWT to keep subject + expiry visible while hiding the signature. */ private jwtSummary; constructor(options: InitializeOptions, cryptoService: CryptoService, sessionManager: SessionManager, storage: import('./types').KeyStorageAdapter); prepareAttachmentAndUpload(bytes: Uint8Array, options: PrepareAttachmentOptions): Promise; createUploadSession(options: CreateUploadSessionOptions): Promise; finalizeAttachment(attachmentId: string, sha256: string): Promise; downloadAttachment(ref: AttachmentRef, options?: import('../attachment/attachment-types').DownloadAttachmentOptions): Promise; revokeAttachment(attachment: string | AttachmentRef): Promise; /** * How the identity private key is held. See {@link KeyCustody}. * * Read from the crypto service rather than remembered at construction, so it * reflects what is actually in use rather than what was requested. */ get keyCustody(): KeyCustody; connect(): Promise; disconnect(): void; sendMessage(toUserId: string, plaintextMessage: string, options?: { attachments?: AttachmentRef[]; clientMessageId?: string; push?: PushPayload; }): Promise<{ messageId: string; }>; /** * Send raw frame bytes with a fresh open-check on a captured transport ref. * sendMessage's open-check happens before a long async encryption phase; the * socket can close during it. Re-checking here (and letting the transport's own * guard throw cleanly) avoids `Cannot read properties of null (reading 'send')` * and instead throws a retryable "not connected" the app can handle. */ private sendFrame; ack(messageId: string): Promise; onMessage(callback: MessageCallback): () => void; onEvent(callback: EventCallback): () => void; onMessageEdit(callback: MessageEditCallback): () => void; onMessageDelete(callback: MessageDeleteCallback): () => void; /** * Listen for edits of group messages. An edit arrives live and again from the * offline fetch, so applying one twice must be harmless. */ onGroupMessageEdit(callback: GroupMessageEditCallback): () => void; /** * Listen for deletes of group messages. A delete arrives live and again from * the offline fetch, and can name a message this device never received. */ onGroupMessageDelete(callback: GroupMessageDeleteCallback): () => void; /** * Register a listener for read receipts that this user's OTHER devices * reported. Fires when another device of the same user calls markRead(); * use it to clear the unread state your app maintains for that message. */ /** * Listen for someone starting or stopping typing in a conversation. Nothing is * stored, so this only fires while both sides are connected. */ onTyping(callback: TypingCallback): () => void; onReadReceipt(callback: ReadReceiptCallback): () => void; editMessage(originalMessageId: string, toUserId: string, newPlaintext: string): Promise<{ editId: string; }>; editCleartextMessage(originalMessageId: string, toUserId: string, newPlaintext: string): Promise<{ editId: string; }>; deleteMessage(originalMessageId: string, toUserId: string, scope: 'FOR_EVERYONE' | 'FOR_ME'): Promise<{ deleteId: string; }>; /** * Edit a message you sent to a group. Encrypted per member and per device with * the edit id, exactly as {@link sendGroupMessage} encrypts with the message id, * so pass the same members. Only the sender of a message can edit it. */ editGroupMessage(groupId: string, originalMessageId: string, newPlaintext: string, memberUserIds: string[]): Promise<{ editId: string; }>; /** Edit a cleartext message you sent to a group. */ editCleartextGroupMessage(groupId: string, originalMessageId: string, newPlaintext: string): Promise<{ editId: string; }>; /** * Delete a message you sent to a group. FOR_EVERYONE tells every member, and a * member who fetches later is not handed the message. FOR_ME tells only your * other devices. Only the sender of a message can delete it. */ deleteGroupMessage(groupId: string, originalMessageId: string, scope?: 'FOR_EVERYONE' | 'FOR_ME'): Promise<{ deleteId: string; }>; /** * Register this device's push notification token with the DropOnAir platform. * The token is opaque to the server; the platform fans out push notifications * via the customer's APNs / FCM / VAPID credentials when a sender attaches a * pushPayload to a message and the recipient is offline. * * For iOS apps using VoIP push (PushKit), pass {@code voipToken} alongside the * regular APNs token. The platform will route CALL_INVITE pushes via the VoIP * token and message pushes via the regular token. * * * For an APNs token, {@code apnsEnvironment} says which APNs host issued it: * 'sandbox' for a development-signed build or the Simulator, 'production' for * App Store, TestFlight and ad hoc builds. The relay then sends each token to * the host that issued it, so development and store builds of the same app * both receive pushes. Leave it out and the app's own sandbox setting decides, * as before. A JavaScript runtime usually cannot tell which it is, so pass it * from the native layer if you have one (the iOS SDK does this on its own). * * Idempotent: calling twice with the same (platform, deviceId) upserts the * stored token and refreshes its lastSeenAt timestamp. */ registerPushToken(opts: { platform: 'APNS' | 'FCM' | 'WEB_PUSH'; token: string; voipToken?: string; apnsEnvironment?: 'sandbox' | 'production'; }): Promise; /** Registers the held token, if there is one. Called on every connect. */ private sendPushRegistration; /** * Unregister this device's push notification token (e.g. on logout). Future * push fan-out for this (appId, userId, deviceId, platform) tuple is dropped. */ unregisterPushToken(opts: { platform: 'APNS' | 'FCM' | 'WEB_PUSH'; }): Promise; /** * List the current user's registered devices. Each device is implicitly * trusted once it has completed a connection. Useful for a "Your devices" * settings screen. */ listMyDevices(): Promise; /** * Revoke one of the current user's devices. The revoked device's live * WebSocket session (if any) is closed immediately and all future * connection attempts from that device are denied. Revoke is permanent; * the user must re-register the device (which yields a fresh deviceId). */ revokeMyDevice(deviceId: string): Promise; /** * Handles an inbound DEVICE_REVOKED event: this device has been revoked * (by the user from another device, or by the app owner). Stops the * reconnect loop and closes the socket. The DEVICE_REVOKED event is also * surfaced through onEvent so the app can clear local key storage and * show a re-register prompt. */ private handleDeviceRevoked; /** * Mark a message as read. Call this when YOUR app decides a message has * been read - the platform never infers read state for you. The receipt * is relayed to this user's other devices so they can clear their unread * UI for the same message; it is not delivered to the message's sender. * * If the app owner has disabled read receipts in the dashboard, the server * silently drops the frame. * * @param messageId the message that was read * @param conversationId optional peer userId / group id, echoed back to * other devices so they can bucket the receipt */ /** * Say this user started or stopped composing in a conversation. * * Never stored, so it reaches only those connected at the time. Send `false` * when they stop, but treat the indicator as expiring on its own: if the sender * disconnects mid-sentence, the stop never arrives. */ sendTyping(conversationId: string, isTyping: boolean): void; /** * Send an app-defined ephemeral signal to a conversation. * * `name` is whatever this app calls it, for example "file.sending"; the * platform carries it and never reads it. `data` is whatever rides with it, * and both together must stay small: a signal is a hint, not a message, and * the relay drops anything past two kilobytes. * * Never stored, so it reaches only those connected at the time. Nothing is * thrown when the socket is down, for the same reason typing throws nothing: * a hint nobody received is not worth an error. */ sendSignal(conversationId: string, name: string, data?: string): void; /** Register a listener for app-defined signals from a conversation. */ onSignal(callback: SignalCallback): () => void; markRead(messageId: string, conversationId?: string): void; /** * Tell this user's other devices that the notification(s) for a * conversation have been dismissed - call this when YOUR app dismisses a * notification or the user opens the conversation. The other devices clear * the matching badge. The relay never decides what "dismissed" means. */ clearNotification(conversationId: string): void; /** * Push the current draft text for a conversation to this user's other * devices so the user can continue typing on another device. Draft sync is * opt-in: the app owner must enable it in the dashboard, and the draft text * crosses the relay in cleartext (it is fanned out, never stored). If the * feature is disabled the server silently drops the frame. */ syncDraft(conversationId: string, draftText: string): void; /** Register a listener for notification-clear syncs from the user's other devices. */ onNotificationCleared(callback: NotificationClearCallback): () => void; /** Register a listener for draft syncs from the user's other devices. */ onDraftSync(callback: DraftSyncCallback): () => void; private handleIncomingSync; sendCleartextMessage(toUserId: string, plaintext: string): Promise<{ messageId: string; }>; subscribeBroadcast(channelId: string): Promise; unsubscribeBroadcast(channelId: string): Promise; publishBroadcast(channelId: string, plaintext: string): Promise<{ broadcastId: string; }>; onBroadcast(callback: BroadcastCallback): () => void; onCallEvent(callback: CallEventCallback): () => void; createGroup(name: string, memberUserIds?: string[]): Promise; listGroups(): Promise; getGroup(groupId: string): Promise; /** * Update a group. Any omitted field is left unchanged. `readReceiptsVisibleToGroup` * controls whether members' group read receipts are visible to * the whole group or stay own-device only. */ updateGroup(groupId: string, update: { name?: string; readReceiptsVisibleToGroup?: boolean; }): Promise; addGroupMembers(groupId: string, userIds: string[]): Promise; removeGroupMember(groupId: string, userId: string): Promise; deleteGroup(groupId: string): Promise; createRoom(options?: CreateRoomOptions): Promise; listRooms(): Promise; getRoom(roomId: string): Promise; updateRoom(roomId: string, update: UpdateRoomOptions): Promise; deleteRoom(roomId: string): Promise; getSfuToken(roomId: string): Promise; startSfuRecording(roomId: string, destinationId: string): Promise; stopSfuRecording(roomId: string, recordingId: string): Promise; listSfuRecordings(roomId: string): Promise; /** * Join the live call in a room. Resolves with the callId once joined; that * callId works with the existing group-call signaling methods (pass an empty * string for groupId). Rejects with an Error whose message is the reason when * the room is closed (`ROOM_CLOSED`), a host is required but absent * (`HOST_REQUIRED`), or the joiner was placed in the waiting room * (`WAITING_ROOM_PENDING` - listen for GROUP_CALL_WAITING_ROOM_ADMITTED, then * call joinRoom again). */ joinRoom(roomId: string): Promise; leaveRoom(callId: string): Promise; /** * Send an end-to-end encrypted group message. Mirrors the Android and iOS * SDKs: the SDK encrypts the plaintext per recipient device (sender-side * fan-out), producing one `GroupMemberPayload` per member that the server * then routes to that member's devices without ever seeing the plaintext. * * `memberUserIds` should be the current group membership minus the sender; * fetch it from your backend / `getGroup()` before calling. * * For unencrypted group messaging (bots, public channels), use * {@link sendCleartextGroupMessage} instead. */ sendGroupMessage(groupId: string, plaintext: string, memberUserIds: string[], options?: { attachments?: AttachmentRef[]; push?: PushPayload; }): Promise<{ messageId: string; }>; /** * Send a plaintext (non-encrypted) group message. The server fans out the * cleartext to every other member of the group; useful for bots and public * channels where E2EE isn't required. * * For E2EE group messaging, use {@link sendGroupMessage} which encrypts * per-recipient before sending. */ sendCleartextGroupMessage(groupId: string, plaintext: string): Promise<{ messageId: string; }>; onGroupMessage(callback: GroupMessageCallback): () => void; /** * Start a call in a group. * * Pass `options.push` to reach members whose app is not connected. It is * decided per member: anybody holding a connection is rung over it, and only * the rest are woken, so a room where half the people are looking at their * phones does not send anybody a pointless notification. * * Pass `options.ring` to call some of the group rather than all of it. A * group can hold more people than one call can carry, and only the caller * knows which of them this call is for. Leave it out and everybody is rung, * which is what this has always done. */ startGroupCall(groupId: string, options?: { push?: PushPayload; ring?: string[]; }): Promise; joinGroupCall(callId: string, groupId: string): Promise; /** Decline an incoming group-call invite. */ rejectGroupCall(callId: string, groupId: string): Promise; leaveGroupCall(callId: string): Promise; endGroupCall(callId: string): Promise; sendGroupCallSignal(type: 'GROUP_CALL_SDP_OFFER' | 'GROUP_CALL_SDP_ANSWER' | 'GROUP_CALL_ICE_CANDIDATE', callId: string, groupId: string, targetUserId: string, payload: string): void; startGroupScreenShare(callId: string, groupId: string, payload?: string): void; stopGroupScreenShare(callId: string, groupId: string, payload?: string): void; onGroupCallEvent(callback: GroupCallEventCallback): () => void; transferHost(callId: string, groupId: string, newHostUserId: string): void; appointCoHost(callId: string, groupId: string, userId: string): void; revokeCoHost(callId: string, groupId: string, userId: string): void; muteParticipant(callId: string, groupId: string, targetUserId: string): void; removeParticipant(callId: string, groupId: string, targetUserId: string): void; requestWaitingRoomEntry(callId: string, groupId: string): void; admitFromWaitingRoom(callId: string, groupId: string, userId: string): void; rejectFromWaitingRoom(callId: string, groupId: string, userId: string): void; raiseHand(callId: string): void; lowerHand(callId: string): void; promoteToSpeaker(callId: string, userId: string): void; demoteToAudience(callId: string, userId: string): void; submitStageQuestion(callId: string, text: string): void; startRecording(callId: string): void; stopRecording(callId: string): void; /** Announce an uploaded recording; `location` is a reference into your storage. */ markRecordingAvailable(callId: string, location: string): void; private sendGroupCallFrame; /** * Initiate an outgoing call. * Sends CALL_INVITE to the server; resolves with the server-assigned callId * once the server echoes back CALL_RINGING (which contains the real callId). * * Pass `options.push` to reach somebody whose app is not connected. Without * it the call only reaches a device that already holds a connection open, and * an invite to anybody else is refused with CALLEE_OFFLINE. With it the relay * wakes them instead, adding the call id to your `customJson` so the woken app * knows what it is being asked to answer, and telling the same device again * when there is no longer anything to answer. */ startCall(targetUserId: string, options?: { push?: PushPayload; }): Promise; acceptCall(callId: string): Promise; rejectCall(callId: string): Promise; endCall(callId: string): Promise; toggleVideo(callId: string, enabled: boolean): void; sendCallSignal(type: 'CALL_SDP_OFFER' | 'CALL_SDP_ANSWER' | 'CALL_ICE_CANDIDATE', callId: string, payload: string): void; startScreenShare(callId: string, payload?: string): void; stopScreenShare(callId: string, payload?: string): void; fetchTurnCredentials(): Promise; private sendCallFrame; private emitCallEvent; private emitEvent; private emitMessage; private emitBroadcast; private handleIncomingGroupMessage; private emitGroupCallEvent; private resolveTransport; private connectWebSocket; /** * One group edit, live or from the offline fetch. Decrypted exactly as a group * message is, with the edit id in place of the message id. */ private handleIncomingGroupEdit; private handleIncomingMessageEdit; private handleIncomingMessageDelete; private handleIncomingEnvelope; private getPeerSharedKey; /** Get or create a persistent device UUID for this SDK instance. */ private getOrCreateDeviceId; /** * Fetch all device keys for a given user (cached with short TTL). * Falls back to wrapping the legacy single key into a device key entry. */ private fetchDeviceKeys; /** * Fetch my own device keys and filter out the current device. * Used for self-sync: encrypt sent messages for my other devices. */ private fetchMyOtherDeviceKeys; private ensureIdentityPublished; /** * Group messages that arrived while this device had no connection. * * Without this a group message is delivered only to sessions that happen to be * live at that instant and is unreachable afterwards, so anyone closed loses it. * Fetching is also what tells the sender the message reached this member. */ /** * Catches up on who has the messages this client sent. * * A receipt used to be a frame on a live socket and nothing else, so a sender * that was away when someone fetched or read never found out and the tick * stayed where it was. The state is kept per member, and this replays it as the * events that would have arrived live, so an app needs no separate path for it. */ private fetchGroupReceipts; private fetchAndProcessOfflineGroupMessages; private fetchAndProcessOfflineMessages; private getValidDropOnAirJwt; private extractSubject; private isJwtExpiringSoon; }