@abraca/dabra 1.6.0 → 1.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -826,6 +826,13 @@ declare class AbracadabraProvider extends AbracadabraBaseProvider {
826
826
  * the offline store is disabled or when no snapshot has been saved yet.
827
827
  */
828
828
  readonly ready: Promise<void>;
829
+ /**
830
+ * True once `_initFromOfflineStore()` has applied a non-empty snapshot or
831
+ * at least one pending update to the Y.Doc. Lets the UI decide to render
832
+ * cached content immediately instead of waiting for a server round-trip.
833
+ * Only meaningful after `await ready`.
834
+ */
835
+ hasCachedContent: boolean;
829
836
  constructor(configuration: AbracadabraProviderConfiguration);
830
837
  /**
831
838
  * Extract the server hostname from the provider configuration.
@@ -1344,6 +1351,77 @@ interface DocEncryptionInfo {
1344
1351
  effective_mode: "none" | "cse" | "e2e";
1345
1352
  inherited_from?: string;
1346
1353
  }
1354
+ interface ChatMessage {
1355
+ id: string;
1356
+ channel: string;
1357
+ sender_id: string;
1358
+ sender_name?: string | null;
1359
+ content: string;
1360
+ created_at: number;
1361
+ }
1362
+ interface ChatChannel {
1363
+ channel: string;
1364
+ label?: string | null;
1365
+ last_message: ChatMessage;
1366
+ unread_count: number;
1367
+ }
1368
+ interface ChatTypingEvent {
1369
+ channel: string;
1370
+ sender_id: string;
1371
+ sender_name?: string | null;
1372
+ }
1373
+ interface ChatReadReceipt {
1374
+ channel: string;
1375
+ user_id: string;
1376
+ last_read_at: number;
1377
+ }
1378
+ interface ChatReadCursor {
1379
+ user_id: string;
1380
+ last_read_at: number;
1381
+ }
1382
+ interface SendChatMessageInput {
1383
+ channel: string;
1384
+ content: string;
1385
+ sender_name?: string;
1386
+ }
1387
+ interface GetChatHistoryInput {
1388
+ channel: string;
1389
+ before?: number;
1390
+ limit?: number;
1391
+ }
1392
+ interface NotificationRecord {
1393
+ id: string;
1394
+ recipient_id: string;
1395
+ notification_type: string;
1396
+ title: string;
1397
+ body: string;
1398
+ icon?: string | null;
1399
+ link?: string | null;
1400
+ source_id?: string | null;
1401
+ read: boolean;
1402
+ created_at: number;
1403
+ }
1404
+ interface NotificationReadUpdate {
1405
+ /** Present when a specific set of notifications was marked read. */
1406
+ ids?: string[];
1407
+ /** Present when mark_all_read was called. */
1408
+ all?: boolean;
1409
+ recipient_id: string;
1410
+ }
1411
+ interface CreateNotificationInput {
1412
+ recipient_id: string;
1413
+ notification_type?: string;
1414
+ title: string;
1415
+ body?: string;
1416
+ icon?: string;
1417
+ link?: string;
1418
+ source_id?: string;
1419
+ }
1420
+ interface FetchNotificationsInput {
1421
+ before?: number;
1422
+ limit?: number;
1423
+ unread_only?: boolean;
1424
+ }
1347
1425
  //#endregion
1348
1426
  //#region packages/provider/src/AbracadabraWS.d.ts
1349
1427
  type AbracadabraWebSocketConn = WebSocket & {
@@ -1375,7 +1453,14 @@ interface CompleteAbracadabraWSConfiguration {
1375
1453
  */
1376
1454
  WebSocketPolyfill: any;
1377
1455
  /**
1378
- * Disconnect when no message is received for the defined amount of milliseconds.
1456
+ * Disconnect and reconnect when no message is received for the defined amount
1457
+ * of milliseconds.
1458
+ *
1459
+ * Defaults to 30000 ms to match y-protocols/awareness `Awareness.outdatedTime`
1460
+ * (also 30 s). Awareness heartbeats are what normally keep this timer
1461
+ * resetting, so this value should be ≥ the largest `outdatedTime` of any
1462
+ * awareness instance attached to this socket. If you pass a custom awareness
1463
+ * with a non-default `outdatedTime`, pass the matching value here too.
1379
1464
  */
1380
1465
  messageReconnectTimeout: number;
1381
1466
  /**
@@ -2534,14 +2619,22 @@ declare class DevicePairingChannel extends EventEmitter {
2534
2619
  /**
2535
2620
  * Approve the pending pairing request. Calls `client.addKey()` to
2536
2621
  * register Device B's public key, then notifies Device B.
2622
+ *
2623
+ * @param client Authenticated REST client.
2624
+ * @param masterPublicKey Approver's account-level Ed25519 public key (base64url).
2625
+ * Sent to Device B so it can adopt the master's identity doc.
2537
2626
  */
2538
- approve(client: AbracadabraClient): Promise<PairingResult>;
2627
+ approve(client: AbracadabraClient, masterPublicKey?: string): Promise<PairingResult>;
2539
2628
  /**
2540
2629
  * Approve via server-side device invite. Creates a single-use invite code
2541
2630
  * and sends it to Device B over the E2EE channel. Device B redeems it
2542
2631
  * independently via HTTP — Device A can go offline after this.
2632
+ *
2633
+ * @param client Authenticated REST client.
2634
+ * @param masterPublicKey Approver's account-level Ed25519 public key (base64url).
2635
+ * Sent to Device B so it can adopt the master's identity doc.
2543
2636
  */
2544
- approveWithInvite(client: AbracadabraClient): Promise<PairingResult>;
2637
+ approveWithInvite(client: AbracadabraClient, masterPublicKey?: string): Promise<PairingResult>;
2545
2638
  /**
2546
2639
  * Reject the pending pairing request.
2547
2640
  */
@@ -2761,6 +2854,12 @@ declare class IdentityDocProvider extends EventEmitter {
2761
2854
  * Call this to decide whether to run migration from localStorage.
2762
2855
  */
2763
2856
  isEmpty(): boolean;
2857
+ /**
2858
+ * Enable WebRTC P2P sync at runtime.
2859
+ * Use this for claimed/passkey users where E2EE identity derivation
2860
+ * was deferred to avoid biometric prompts on page load.
2861
+ */
2862
+ enableWebRTC(webrtcConfig: NonNullable<IdentityDocConfiguration["webrtc"]>): void;
2764
2863
  /**
2765
2864
  * Update the sync server URL at runtime (e.g. when user changes their
2766
2865
  * designated sync server in settings).
@@ -2826,6 +2925,111 @@ declare class DeviceRegistrationService {
2826
2925
  }): Promise<void>;
2827
2926
  }
2828
2927
  //#endregion
2928
+ //#region packages/provider/src/ChatClient.d.ts
2929
+ /**
2930
+ * Minimal provider surface ChatClient needs. Matches `AbracadabraBaseProvider`.
2931
+ * Kept as an interface so consumers can pass any compatible transport.
2932
+ */
2933
+ interface ChatClientTransport {
2934
+ sendStateless(payload: string): void;
2935
+ on(event: string, fn: Function): unknown;
2936
+ off(event: string, fn?: Function): unknown;
2937
+ }
2938
+ /**
2939
+ * Typed client for the Abracadabra chat feature.
2940
+ *
2941
+ * Wraps a connected provider (or base provider) and translates JSON envelopes
2942
+ * on the stateless channel into typed method calls and events.
2943
+ *
2944
+ * Events emitted:
2945
+ * - `message` → ChatMessage (new message broadcast)
2946
+ * - `typing` → ChatTypingEvent (typing indicator broadcast)
2947
+ * - `readReceipt` → ChatReadReceipt (mark_read broadcast)
2948
+ */
2949
+ declare class ChatClient extends EventEmitter {
2950
+ private readonly provider;
2951
+ private readonly responseTimeoutMs;
2952
+ private readonly pending;
2953
+ private readonly boundOnStateless;
2954
+ constructor(provider: ChatClientTransport, options?: {
2955
+ responseTimeoutMs?: number;
2956
+ });
2957
+ /** Stop listening for chat messages. Does not disconnect the underlying provider. */
2958
+ destroy(): void;
2959
+ /** Send a chat message on a channel. Fire-and-forget (the server broadcasts). */
2960
+ sendMessage(input: SendChatMessageInput): void;
2961
+ /** Fetch historical messages for a channel. Resolves with the server response. */
2962
+ getHistory(input: GetChatHistoryInput): Promise<{
2963
+ channel: string;
2964
+ messages: ChatMessage[];
2965
+ }>;
2966
+ /** Broadcast a typing indicator on a channel. */
2967
+ sendTyping(channel: string): void;
2968
+ /** List the current user's channels (ordered by last activity). */
2969
+ listChannels(): Promise<{
2970
+ channels: ChatChannel[];
2971
+ }>;
2972
+ /** Mark a channel read up to `timestamp` (unix ms). */
2973
+ markRead(channel: string, timestamp: number): void;
2974
+ /** Fetch per-user read cursors for a channel. */
2975
+ getReadCursors(channel: string): Promise<{
2976
+ channel: string;
2977
+ cursors: ChatReadCursor[];
2978
+ }>;
2979
+ onMessage(fn: (m: ChatMessage) => void): this;
2980
+ onTyping(fn: (e: ChatTypingEvent) => void): this;
2981
+ onReadReceipt(fn: (e: ChatReadReceipt) => void): this;
2982
+ private enqueue;
2983
+ private removePending;
2984
+ private resolveNext;
2985
+ private handleStateless;
2986
+ }
2987
+ //#endregion
2988
+ //#region packages/provider/src/NotificationsClient.d.ts
2989
+ /**
2990
+ * Typed client for the Abracadabra notifications feature.
2991
+ *
2992
+ * Emits:
2993
+ * - `new` → NotificationRecord (incoming notify:new broadcast)
2994
+ * - `readUpdate` → NotificationReadUpdate (broadcast after mark_read / mark_all_read)
2995
+ */
2996
+ declare class NotificationsClient extends EventEmitter {
2997
+ private readonly provider;
2998
+ private readonly responseTimeoutMs;
2999
+ private readonly pending;
3000
+ private readonly boundOnStateless;
3001
+ constructor(provider: ChatClientTransport, options?: {
3002
+ responseTimeoutMs?: number;
3003
+ });
3004
+ destroy(): void;
3005
+ /**
3006
+ * Create a notification targeting a specific recipient. Requires elevated role
3007
+ * (service or admin); a `server:error` event with code `forbidden` is emitted
3008
+ * by the underlying provider if the caller lacks permission.
3009
+ */
3010
+ create(input: CreateNotificationInput): void;
3011
+ /** Fetch notification history for the current user. */
3012
+ fetch(input?: FetchNotificationsInput): Promise<{
3013
+ notifications: NotificationRecord[];
3014
+ }>;
3015
+ /** Mark a single notification, or a batch, as read. */
3016
+ markRead(target: {
3017
+ id: string;
3018
+ } | {
3019
+ ids: string[];
3020
+ }): void;
3021
+ /** Mark every notification for the current user as read. */
3022
+ markAllRead(): void;
3023
+ /** Mark all notifications generated by the given source (e.g. a chat channel) as read. */
3024
+ markReadBySource(sourceId: string): void;
3025
+ onNew(fn: (n: NotificationRecord) => void): this;
3026
+ onReadUpdate(fn: (u: NotificationReadUpdate) => void): this;
3027
+ private enqueue;
3028
+ private removePending;
3029
+ private resolveNext;
3030
+ private handleStateless;
3031
+ }
3032
+ //#endregion
2829
3033
  //#region node_modules/@scure/bip39/esm/wordlists/english.d.ts
2830
3034
  declare const wordlist: string[];
2831
3035
  //#endregion
@@ -2893,4 +3097,4 @@ declare function wrapSeed(seed: Uint8Array, wrappingKeyBytes: Uint8Array): Promi
2893
3097
  */
2894
3098
  declare function unwrapSeed(ciphertext: ArrayBuffer, iv: Uint8Array, wrappingKeyBytes: Uint8Array): Promise<Uint8Array>;
2895
3099
  //#endregion
2896
- export { AbracadabraBaseProvider, AbracadabraBaseProviderConfiguration, AbracadabraClient, AbracadabraClientConfig, AbracadabraOutgoingMessageArguments, AbracadabraProvider, AbracadabraProviderConfiguration, AbracadabraWS, AbracadabraWSConfiguration, AbracadabraWebRTC, type AbracadabraWebRTCConfiguration, AbracadabraWebSocketConn, AuthMessageType, AuthorizedScope, AwarenessError, BackgroundSyncManager, type BackgroundSyncManagerOptions, BackgroundSyncPersistence, BroadcastChannelSync, CHANNEL_NAMES, CloseEvent, CompleteAbracadabraBaseProviderConfiguration, CompleteAbracadabraWSConfiguration, CompleteHocuspocusProviderConfiguration, CompleteHocuspocusProviderWebsocketConfiguration, ConnectionTimeout, Constructable, ConstructableOutgoingMessage, CryptoIdentity, CryptoIdentityKeystore, DEFAULT_FILE_CHUNK_SIZE, DEFAULT_ICE_SERVERS, DataChannelRouter, DevicePairingChannel, type DevicePairingConfig, DeviceRegistrationService, type DeviceServerStatus, type DeviceTier, type DocEncryptionInfo, DocKeyManager, type DocSyncState, DocumentCache, type DocumentCacheOptions, DocumentMeta, E2EAbracadabraProvider, E2EEChannel, type E2EEIdentity, E2EOfflineStore, EffectivePermissionEntry, EffectivePermissionsResponse, EffectiveRole, EncryptedYMap, EncryptedYText, FileBlobStore, FileTransferChannel, FileTransferHandle, type FileTransferMeta, type FileTransferStatus, Forbidden, HealthStatus, HocusPocusWebSocket, HocuspocusProvider, HocuspocusProviderConfiguration, HocuspocusProviderWebsocket, HocuspocusProviderWebsocketConfiguration, HocuspocusWebSocket, type IdentityDeviceEntry, type IdentityDocConfiguration, IdentityDocProvider, type IdentityProfile, type IdentityServerEntry, type IdentitySpaceEntry, InviteRow, KEY_EXCHANGE_CHANNEL, ManualSignaling, type ManualSignalingBlob, MessageTooBig, MessageType, OfflineStore, OutgoingMessageArguments, OutgoingMessageInterface, type PairingRequest, type PairingResult, PeerConnection, type PeerInfo, type PeerState, PendingSubdoc, PermissionEntry, PublicKeyInfo, ResetConnection, SearchIndex, SearchResult, ServerInfo, type SignalingIncoming, type SignalingOutgoing, SignalingSocket, SnapshotCreateResult, SnapshotData, SnapshotForkResult, SnapshotMeta, SnapshotRestoreResult, SpaceMeta, StatesArray, SubdocMessage, SubdocRegisteredEvent, Unauthorized, UploadInfo, UploadMeta, UploadQueueEntry, UploadQueueStatus, UserProfile, WebSocketStatus, WsReadyStates, YjsDataChannel, attachUpdatedAtObserver, awarenessStatesToArray, wordlist as bip39Wordlist, decryptField, deriveIdentityDocId, deriveSeedWrappingKey, encryptField, generateMnemonic, makeEncryptedYMap, makeEncryptedYText, mnemonicToEd25519Seed, mnemonicToKeyPair, onAuthenticatedParameters, onAuthenticationFailedParameters, onAwarenessChangeParameters, onAwarenessUpdateParameters, onCloseParameters, onDisconnectParameters, onMessageParameters, onOpenParameters, onOutgoingMessageParameters, onServerErrorParameters, onStatelessParameters, onStatusParameters, onSubdocLoadedParameters, onSubdocRegisteredParameters, onSyncedParameters, onUnsyncedChangesParameters, readAuthMessage, unwrapSeed, validateMnemonic, wrapSeed, writeAuthenticated, writeAuthentication, writePermissionDenied, writeTokenSyncRequest };
3100
+ export { AbracadabraBaseProvider, AbracadabraBaseProviderConfiguration, AbracadabraClient, AbracadabraClientConfig, AbracadabraOutgoingMessageArguments, AbracadabraProvider, AbracadabraProviderConfiguration, AbracadabraWS, AbracadabraWSConfiguration, AbracadabraWebRTC, type AbracadabraWebRTCConfiguration, AbracadabraWebSocketConn, AuthMessageType, AuthorizedScope, AwarenessError, BackgroundSyncManager, type BackgroundSyncManagerOptions, BackgroundSyncPersistence, BroadcastChannelSync, CHANNEL_NAMES, type ChatChannel, ChatClient, type ChatClientTransport, type ChatMessage, type ChatReadCursor, type ChatReadReceipt, type ChatTypingEvent, CloseEvent, CompleteAbracadabraBaseProviderConfiguration, CompleteAbracadabraWSConfiguration, CompleteHocuspocusProviderConfiguration, CompleteHocuspocusProviderWebsocketConfiguration, ConnectionTimeout, Constructable, ConstructableOutgoingMessage, type CreateNotificationInput, CryptoIdentity, CryptoIdentityKeystore, DEFAULT_FILE_CHUNK_SIZE, DEFAULT_ICE_SERVERS, DataChannelRouter, DevicePairingChannel, type DevicePairingConfig, DeviceRegistrationService, type DeviceServerStatus, type DeviceTier, type DocEncryptionInfo, DocKeyManager, type DocSyncState, DocumentCache, type DocumentCacheOptions, DocumentMeta, E2EAbracadabraProvider, E2EEChannel, type E2EEIdentity, E2EOfflineStore, EffectivePermissionEntry, EffectivePermissionsResponse, EffectiveRole, EncryptedYMap, EncryptedYText, type FetchNotificationsInput, FileBlobStore, FileTransferChannel, FileTransferHandle, type FileTransferMeta, type FileTransferStatus, Forbidden, type GetChatHistoryInput, HealthStatus, HocusPocusWebSocket, HocuspocusProvider, HocuspocusProviderConfiguration, HocuspocusProviderWebsocket, HocuspocusProviderWebsocketConfiguration, HocuspocusWebSocket, type IdentityDeviceEntry, type IdentityDocConfiguration, IdentityDocProvider, type IdentityProfile, type IdentityServerEntry, type IdentitySpaceEntry, InviteRow, KEY_EXCHANGE_CHANNEL, ManualSignaling, type ManualSignalingBlob, MessageTooBig, MessageType, type NotificationReadUpdate, type NotificationRecord, NotificationsClient, OfflineStore, OutgoingMessageArguments, OutgoingMessageInterface, type PairingRequest, type PairingResult, PeerConnection, type PeerInfo, type PeerState, PendingSubdoc, PermissionEntry, PublicKeyInfo, ResetConnection, SearchIndex, SearchResult, type SendChatMessageInput, ServerInfo, type SignalingIncoming, type SignalingOutgoing, SignalingSocket, SnapshotCreateResult, SnapshotData, SnapshotForkResult, SnapshotMeta, SnapshotRestoreResult, SpaceMeta, StatesArray, SubdocMessage, SubdocRegisteredEvent, Unauthorized, UploadInfo, UploadMeta, UploadQueueEntry, UploadQueueStatus, UserProfile, WebSocketStatus, WsReadyStates, YjsDataChannel, attachUpdatedAtObserver, awarenessStatesToArray, wordlist as bip39Wordlist, decryptField, deriveIdentityDocId, deriveSeedWrappingKey, encryptField, generateMnemonic, makeEncryptedYMap, makeEncryptedYText, mnemonicToEd25519Seed, mnemonicToKeyPair, onAuthenticatedParameters, onAuthenticationFailedParameters, onAwarenessChangeParameters, onAwarenessUpdateParameters, onCloseParameters, onDisconnectParameters, onMessageParameters, onOpenParameters, onOutgoingMessageParameters, onServerErrorParameters, onStatelessParameters, onStatusParameters, onSubdocLoadedParameters, onSubdocRegisteredParameters, onSyncedParameters, onUnsyncedChangesParameters, readAuthMessage, unwrapSeed, validateMnemonic, wrapSeed, writeAuthenticated, writeAuthentication, writePermissionDenied, writeTokenSyncRequest };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abraca/dabra",
3
- "version": "1.6.0",
3
+ "version": "1.8.1",
4
4
  "description": "abracadabra provider",
5
5
  "keywords": [
6
6
  "abracadabra",
@@ -129,6 +129,14 @@ export class AbracadabraProvider extends AbracadabraBaseProvider {
129
129
  */
130
130
  public readonly ready: Promise<void>;
131
131
 
132
+ /**
133
+ * True once `_initFromOfflineStore()` has applied a non-empty snapshot or
134
+ * at least one pending update to the Y.Doc. Lets the UI decide to render
135
+ * cached content immediately instead of waiting for a server round-trip.
136
+ * Only meaningful after `await ready`.
137
+ */
138
+ public hasCachedContent = false;
139
+
132
140
  constructor(configuration: AbracadabraProviderConfiguration) {
133
141
  // Derive URL and token from client when not explicitly set.
134
142
  const resolved = { ...configuration } as AbracadabraBaseProviderConfiguration;
@@ -225,9 +233,11 @@ export class AbracadabraProvider extends AbracadabraBaseProvider {
225
233
 
226
234
  if (snapshot) {
227
235
  Y.applyUpdate(this.document, snapshot, this.offlineStore);
236
+ this.hasCachedContent = true;
228
237
  }
229
238
  for (const update of pending) {
230
239
  Y.applyUpdate(this.document, update, this.offlineStore);
240
+ this.hasCachedContent = true;
231
241
  }
232
242
  }
233
243
 
@@ -55,7 +55,14 @@ export interface CompleteAbracadabraWSConfiguration {
55
55
  WebSocketPolyfill: any;
56
56
 
57
57
  /**
58
- * Disconnect when no message is received for the defined amount of milliseconds.
58
+ * Disconnect and reconnect when no message is received for the defined amount
59
+ * of milliseconds.
60
+ *
61
+ * Defaults to 30000 ms to match y-protocols/awareness `Awareness.outdatedTime`
62
+ * (also 30 s). Awareness heartbeats are what normally keep this timer
63
+ * resetting, so this value should be ≥ the largest `outdatedTime` of any
64
+ * awareness instance attached to this socket. If you pass a custom awareness
65
+ * with a non-default `outdatedTime`, pass the matching value here too.
59
66
  */
60
67
  messageReconnectTimeout: number;
61
68
  /**
@@ -121,7 +128,8 @@ export class AbracadabraWS extends EventEmitter {
121
128
  // @ts-ignore
122
129
  document: undefined,
123
130
  WebSocketPolyfill: undefined,
124
- // TODO: this should depend on awareness.outdatedTime
131
+ // Matches y-protocols/awareness Awareness.outdatedTime default (30 s).
132
+ // See the `messageReconnectTimeout` JSDoc on CompleteAbracadabraWSConfiguration.
125
133
  messageReconnectTimeout: 30000,
126
134
  // 1 second
127
135
  delay: 1000,
@@ -0,0 +1,234 @@
1
+ import EventEmitter from "./EventEmitter.ts";
2
+ import type {
3
+ ChatChannel,
4
+ ChatMessage,
5
+ ChatReadCursor,
6
+ ChatReadReceipt,
7
+ ChatTypingEvent,
8
+ GetChatHistoryInput,
9
+ SendChatMessageInput,
10
+ } from "./types.ts";
11
+
12
+ /**
13
+ * Minimal provider surface ChatClient needs. Matches `AbracadabraBaseProvider`.
14
+ * Kept as an interface so consumers can pass any compatible transport.
15
+ */
16
+ export interface ChatClientTransport {
17
+ sendStateless(payload: string): void;
18
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
19
+ on(event: string, fn: Function): unknown;
20
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
21
+ off(event: string, fn?: Function): unknown;
22
+ }
23
+
24
+ type PendingResolver<T> = {
25
+ resolve: (value: T) => void;
26
+ reject: (err: Error) => void;
27
+ timer: ReturnType<typeof setTimeout>;
28
+ };
29
+
30
+ const DEFAULT_TIMEOUT_MS = 10_000;
31
+
32
+ /**
33
+ * Typed client for the Abracadabra chat feature.
34
+ *
35
+ * Wraps a connected provider (or base provider) and translates JSON envelopes
36
+ * on the stateless channel into typed method calls and events.
37
+ *
38
+ * Events emitted:
39
+ * - `message` → ChatMessage (new message broadcast)
40
+ * - `typing` → ChatTypingEvent (typing indicator broadcast)
41
+ * - `readReceipt` → ChatReadReceipt (mark_read broadcast)
42
+ */
43
+ export class ChatClient extends EventEmitter {
44
+ private readonly provider: ChatClientTransport;
45
+ private readonly responseTimeoutMs: number;
46
+
47
+ // FIFO of promises waiting on a particular typed server response. The server
48
+ // replies on the same socket, one-per-request, so a simple queue per type
49
+ // matches observed behavior.
50
+ private readonly pending: Map<string, PendingResolver<any>[]> = new Map();
51
+
52
+ private readonly boundOnStateless: (data: { payload: string }) => void;
53
+
54
+ constructor(provider: ChatClientTransport, options?: { responseTimeoutMs?: number }) {
55
+ super();
56
+ this.provider = provider;
57
+ this.responseTimeoutMs = options?.responseTimeoutMs ?? DEFAULT_TIMEOUT_MS;
58
+ this.boundOnStateless = (data) => this.handleStateless(data.payload);
59
+ this.provider.on("stateless", this.boundOnStateless);
60
+ }
61
+
62
+ /** Stop listening for chat messages. Does not disconnect the underlying provider. */
63
+ destroy(): void {
64
+ this.provider.off("stateless", this.boundOnStateless);
65
+ for (const queue of this.pending.values()) {
66
+ for (const p of queue) {
67
+ clearTimeout(p.timer);
68
+ p.reject(new Error("ChatClient destroyed"));
69
+ }
70
+ }
71
+ this.pending.clear();
72
+ this.removeAllListeners();
73
+ }
74
+
75
+ // ── Outgoing requests ──────────────────────────────────────────────────────
76
+
77
+ /** Send a chat message on a channel. Fire-and-forget (the server broadcasts). */
78
+ sendMessage(input: SendChatMessageInput): void {
79
+ this.provider.sendStateless(
80
+ JSON.stringify({
81
+ type: "chat:send",
82
+ channel: input.channel,
83
+ content: input.content,
84
+ ...(input.sender_name !== undefined ? { sender_name: input.sender_name } : {}),
85
+ }),
86
+ );
87
+ }
88
+
89
+ /** Fetch historical messages for a channel. Resolves with the server response. */
90
+ getHistory(input: GetChatHistoryInput): Promise<{ channel: string; messages: ChatMessage[] }> {
91
+ const promise = this.enqueue<{ channel: string; messages: ChatMessage[] }>("chat:history");
92
+ this.provider.sendStateless(
93
+ JSON.stringify({
94
+ type: "chat:history",
95
+ channel: input.channel,
96
+ ...(input.before !== undefined ? { before: input.before } : {}),
97
+ ...(input.limit !== undefined ? { limit: input.limit } : {}),
98
+ }),
99
+ );
100
+ return promise;
101
+ }
102
+
103
+ /** Broadcast a typing indicator on a channel. */
104
+ sendTyping(channel: string): void {
105
+ this.provider.sendStateless(JSON.stringify({ type: "chat:typing", channel }));
106
+ }
107
+
108
+ /** List the current user's channels (ordered by last activity). */
109
+ listChannels(): Promise<{ channels: ChatChannel[] }> {
110
+ const promise = this.enqueue<{ channels: ChatChannel[] }>("chat:channels");
111
+ this.provider.sendStateless(JSON.stringify({ type: "chat:channels" }));
112
+ return promise;
113
+ }
114
+
115
+ /** Mark a channel read up to `timestamp` (unix ms). */
116
+ markRead(channel: string, timestamp: number): void {
117
+ this.provider.sendStateless(
118
+ JSON.stringify({ type: "chat:mark_read", channel, timestamp }),
119
+ );
120
+ }
121
+
122
+ /** Fetch per-user read cursors for a channel. */
123
+ getReadCursors(channel: string): Promise<{ channel: string; cursors: ChatReadCursor[] }> {
124
+ const promise = this.enqueue<{ channel: string; cursors: ChatReadCursor[] }>(
125
+ "chat:read_cursors",
126
+ );
127
+ this.provider.sendStateless(JSON.stringify({ type: "chat:read_cursors", channel }));
128
+ return promise;
129
+ }
130
+
131
+ // ── Typed event subscription helpers (optional sugar over EventEmitter) ───
132
+
133
+ onMessage(fn: (m: ChatMessage) => void): this {
134
+ return this.on("message", fn) as this;
135
+ }
136
+
137
+ onTyping(fn: (e: ChatTypingEvent) => void): this {
138
+ return this.on("typing", fn) as this;
139
+ }
140
+
141
+ onReadReceipt(fn: (e: ChatReadReceipt) => void): this {
142
+ return this.on("readReceipt", fn) as this;
143
+ }
144
+
145
+ // ── Internals ─────────────────────────────────────────────────────────────
146
+
147
+ private enqueue<T>(type: string): Promise<T> {
148
+ return new Promise<T>((resolve, reject) => {
149
+ const timer = setTimeout(() => {
150
+ this.removePending(type, entry);
151
+ reject(new Error(`ChatClient: timeout waiting for ${type} response`));
152
+ }, this.responseTimeoutMs);
153
+ const entry: PendingResolver<T> = { resolve, reject, timer };
154
+ const queue = this.pending.get(type) ?? [];
155
+ queue.push(entry);
156
+ this.pending.set(type, queue);
157
+ });
158
+ }
159
+
160
+ private removePending(type: string, entry: PendingResolver<any>): void {
161
+ const queue = this.pending.get(type);
162
+ if (!queue) return;
163
+ const idx = queue.indexOf(entry);
164
+ if (idx >= 0) queue.splice(idx, 1);
165
+ if (queue.length === 0) this.pending.delete(type);
166
+ }
167
+
168
+ private resolveNext<T>(type: string, value: T): boolean {
169
+ const queue = this.pending.get(type);
170
+ if (!queue || queue.length === 0) return false;
171
+ const next = queue.shift()!;
172
+ if (queue.length === 0) this.pending.delete(type);
173
+ clearTimeout(next.timer);
174
+ next.resolve(value);
175
+ return true;
176
+ }
177
+
178
+ private handleStateless(payload: string): void {
179
+ let parsed: any;
180
+ try {
181
+ parsed = JSON.parse(payload);
182
+ } catch {
183
+ return;
184
+ }
185
+ const type: unknown = parsed?.type;
186
+ if (typeof type !== "string" || !type.startsWith("chat:")) return;
187
+
188
+ switch (type) {
189
+ case "chat:message": {
190
+ // Server flattens the ChatMessage fields at the top level alongside `type`.
191
+ const { type: _t, ...rest } = parsed;
192
+ this.emit("message", rest as ChatMessage);
193
+ break;
194
+ }
195
+ case "chat:history": {
196
+ this.resolveNext("chat:history", {
197
+ channel: parsed.channel,
198
+ messages: parsed.messages ?? [],
199
+ });
200
+ break;
201
+ }
202
+ case "chat:channels": {
203
+ this.resolveNext("chat:channels", { channels: parsed.channels ?? [] });
204
+ break;
205
+ }
206
+ case "chat:typing": {
207
+ this.emit("typing", {
208
+ channel: parsed.channel,
209
+ sender_id: parsed.sender_id,
210
+ sender_name: parsed.sender_name ?? null,
211
+ } as ChatTypingEvent);
212
+ break;
213
+ }
214
+ case "chat:read_receipt": {
215
+ this.emit("readReceipt", {
216
+ channel: parsed.channel,
217
+ user_id: parsed.user_id,
218
+ last_read_at: parsed.last_read_at,
219
+ } as ChatReadReceipt);
220
+ break;
221
+ }
222
+ case "chat:read_cursors": {
223
+ this.resolveNext("chat:read_cursors", {
224
+ channel: parsed.channel,
225
+ cursors: parsed.cursors ?? [],
226
+ });
227
+ break;
228
+ }
229
+ default:
230
+ // Unknown chat:* subtype — ignore for forward compat.
231
+ break;
232
+ }
233
+ }
234
+ }
@@ -626,6 +626,17 @@ export class IdentityDocProvider extends EventEmitter {
626
626
 
627
627
  // ── Lifecycle ────────────────────────────────────────────────────────────
628
628
 
629
+ /**
630
+ * Enable WebRTC P2P sync at runtime.
631
+ * Use this for claimed/passkey users where E2EE identity derivation
632
+ * was deferred to avoid biometric prompts on page load.
633
+ */
634
+ enableWebRTC(webrtcConfig: NonNullable<IdentityDocConfiguration["webrtc"]>): void {
635
+ if (this._destroyed || this.webrtc) return;
636
+ this.config = { ...this.config, webrtc: webrtcConfig };
637
+ this._connectWebRTC();
638
+ }
639
+
629
640
  /**
630
641
  * Update the sync server URL at runtime (e.g. when user changes their
631
642
  * designated sync server in settings).