@abraca/dabra 1.6.0 → 1.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/abracadabra-provider.cjs +346 -6
- package/dist/abracadabra-provider.cjs.map +1 -1
- package/dist/abracadabra-provider.esm.js +345 -7
- package/dist/abracadabra-provider.esm.js.map +1 -1
- package/dist/index.d.ts +193 -3
- package/package.json +1 -1
- package/src/ChatClient.ts +234 -0
- package/src/IdentityDoc.ts +11 -0
- package/src/NotificationsClient.ts +185 -0
- package/src/index.ts +16 -0
- package/src/types.ts +86 -0
- package/src/webrtc/DevicePairingChannel.ts +18 -6
package/dist/index.d.ts
CHANGED
|
@@ -1344,6 +1344,77 @@ interface DocEncryptionInfo {
|
|
|
1344
1344
|
effective_mode: "none" | "cse" | "e2e";
|
|
1345
1345
|
inherited_from?: string;
|
|
1346
1346
|
}
|
|
1347
|
+
interface ChatMessage {
|
|
1348
|
+
id: string;
|
|
1349
|
+
channel: string;
|
|
1350
|
+
sender_id: string;
|
|
1351
|
+
sender_name?: string | null;
|
|
1352
|
+
content: string;
|
|
1353
|
+
created_at: number;
|
|
1354
|
+
}
|
|
1355
|
+
interface ChatChannel {
|
|
1356
|
+
channel: string;
|
|
1357
|
+
label?: string | null;
|
|
1358
|
+
last_message: ChatMessage;
|
|
1359
|
+
unread_count: number;
|
|
1360
|
+
}
|
|
1361
|
+
interface ChatTypingEvent {
|
|
1362
|
+
channel: string;
|
|
1363
|
+
sender_id: string;
|
|
1364
|
+
sender_name?: string | null;
|
|
1365
|
+
}
|
|
1366
|
+
interface ChatReadReceipt {
|
|
1367
|
+
channel: string;
|
|
1368
|
+
user_id: string;
|
|
1369
|
+
last_read_at: number;
|
|
1370
|
+
}
|
|
1371
|
+
interface ChatReadCursor {
|
|
1372
|
+
user_id: string;
|
|
1373
|
+
last_read_at: number;
|
|
1374
|
+
}
|
|
1375
|
+
interface SendChatMessageInput {
|
|
1376
|
+
channel: string;
|
|
1377
|
+
content: string;
|
|
1378
|
+
sender_name?: string;
|
|
1379
|
+
}
|
|
1380
|
+
interface GetChatHistoryInput {
|
|
1381
|
+
channel: string;
|
|
1382
|
+
before?: number;
|
|
1383
|
+
limit?: number;
|
|
1384
|
+
}
|
|
1385
|
+
interface NotificationRecord {
|
|
1386
|
+
id: string;
|
|
1387
|
+
recipient_id: string;
|
|
1388
|
+
notification_type: string;
|
|
1389
|
+
title: string;
|
|
1390
|
+
body: string;
|
|
1391
|
+
icon?: string | null;
|
|
1392
|
+
link?: string | null;
|
|
1393
|
+
source_id?: string | null;
|
|
1394
|
+
read: boolean;
|
|
1395
|
+
created_at: number;
|
|
1396
|
+
}
|
|
1397
|
+
interface NotificationReadUpdate {
|
|
1398
|
+
/** Present when a specific set of notifications was marked read. */
|
|
1399
|
+
ids?: string[];
|
|
1400
|
+
/** Present when mark_all_read was called. */
|
|
1401
|
+
all?: boolean;
|
|
1402
|
+
recipient_id: string;
|
|
1403
|
+
}
|
|
1404
|
+
interface CreateNotificationInput {
|
|
1405
|
+
recipient_id: string;
|
|
1406
|
+
notification_type?: string;
|
|
1407
|
+
title: string;
|
|
1408
|
+
body?: string;
|
|
1409
|
+
icon?: string;
|
|
1410
|
+
link?: string;
|
|
1411
|
+
source_id?: string;
|
|
1412
|
+
}
|
|
1413
|
+
interface FetchNotificationsInput {
|
|
1414
|
+
before?: number;
|
|
1415
|
+
limit?: number;
|
|
1416
|
+
unread_only?: boolean;
|
|
1417
|
+
}
|
|
1347
1418
|
//#endregion
|
|
1348
1419
|
//#region packages/provider/src/AbracadabraWS.d.ts
|
|
1349
1420
|
type AbracadabraWebSocketConn = WebSocket & {
|
|
@@ -2534,14 +2605,22 @@ declare class DevicePairingChannel extends EventEmitter {
|
|
|
2534
2605
|
/**
|
|
2535
2606
|
* Approve the pending pairing request. Calls `client.addKey()` to
|
|
2536
2607
|
* register Device B's public key, then notifies Device B.
|
|
2608
|
+
*
|
|
2609
|
+
* @param client Authenticated REST client.
|
|
2610
|
+
* @param masterPublicKey Approver's account-level Ed25519 public key (base64url).
|
|
2611
|
+
* Sent to Device B so it can adopt the master's identity doc.
|
|
2537
2612
|
*/
|
|
2538
|
-
approve(client: AbracadabraClient): Promise<PairingResult>;
|
|
2613
|
+
approve(client: AbracadabraClient, masterPublicKey?: string): Promise<PairingResult>;
|
|
2539
2614
|
/**
|
|
2540
2615
|
* Approve via server-side device invite. Creates a single-use invite code
|
|
2541
2616
|
* and sends it to Device B over the E2EE channel. Device B redeems it
|
|
2542
2617
|
* independently via HTTP — Device A can go offline after this.
|
|
2618
|
+
*
|
|
2619
|
+
* @param client Authenticated REST client.
|
|
2620
|
+
* @param masterPublicKey Approver's account-level Ed25519 public key (base64url).
|
|
2621
|
+
* Sent to Device B so it can adopt the master's identity doc.
|
|
2543
2622
|
*/
|
|
2544
|
-
approveWithInvite(client: AbracadabraClient): Promise<PairingResult>;
|
|
2623
|
+
approveWithInvite(client: AbracadabraClient, masterPublicKey?: string): Promise<PairingResult>;
|
|
2545
2624
|
/**
|
|
2546
2625
|
* Reject the pending pairing request.
|
|
2547
2626
|
*/
|
|
@@ -2761,6 +2840,12 @@ declare class IdentityDocProvider extends EventEmitter {
|
|
|
2761
2840
|
* Call this to decide whether to run migration from localStorage.
|
|
2762
2841
|
*/
|
|
2763
2842
|
isEmpty(): boolean;
|
|
2843
|
+
/**
|
|
2844
|
+
* Enable WebRTC P2P sync at runtime.
|
|
2845
|
+
* Use this for claimed/passkey users where E2EE identity derivation
|
|
2846
|
+
* was deferred to avoid biometric prompts on page load.
|
|
2847
|
+
*/
|
|
2848
|
+
enableWebRTC(webrtcConfig: NonNullable<IdentityDocConfiguration["webrtc"]>): void;
|
|
2764
2849
|
/**
|
|
2765
2850
|
* Update the sync server URL at runtime (e.g. when user changes their
|
|
2766
2851
|
* designated sync server in settings).
|
|
@@ -2826,6 +2911,111 @@ declare class DeviceRegistrationService {
|
|
|
2826
2911
|
}): Promise<void>;
|
|
2827
2912
|
}
|
|
2828
2913
|
//#endregion
|
|
2914
|
+
//#region packages/provider/src/ChatClient.d.ts
|
|
2915
|
+
/**
|
|
2916
|
+
* Minimal provider surface ChatClient needs. Matches `AbracadabraBaseProvider`.
|
|
2917
|
+
* Kept as an interface so consumers can pass any compatible transport.
|
|
2918
|
+
*/
|
|
2919
|
+
interface ChatClientTransport {
|
|
2920
|
+
sendStateless(payload: string): void;
|
|
2921
|
+
on(event: string, fn: Function): unknown;
|
|
2922
|
+
off(event: string, fn?: Function): unknown;
|
|
2923
|
+
}
|
|
2924
|
+
/**
|
|
2925
|
+
* Typed client for the Abracadabra chat feature.
|
|
2926
|
+
*
|
|
2927
|
+
* Wraps a connected provider (or base provider) and translates JSON envelopes
|
|
2928
|
+
* on the stateless channel into typed method calls and events.
|
|
2929
|
+
*
|
|
2930
|
+
* Events emitted:
|
|
2931
|
+
* - `message` → ChatMessage (new message broadcast)
|
|
2932
|
+
* - `typing` → ChatTypingEvent (typing indicator broadcast)
|
|
2933
|
+
* - `readReceipt` → ChatReadReceipt (mark_read broadcast)
|
|
2934
|
+
*/
|
|
2935
|
+
declare class ChatClient extends EventEmitter {
|
|
2936
|
+
private readonly provider;
|
|
2937
|
+
private readonly responseTimeoutMs;
|
|
2938
|
+
private readonly pending;
|
|
2939
|
+
private readonly boundOnStateless;
|
|
2940
|
+
constructor(provider: ChatClientTransport, options?: {
|
|
2941
|
+
responseTimeoutMs?: number;
|
|
2942
|
+
});
|
|
2943
|
+
/** Stop listening for chat messages. Does not disconnect the underlying provider. */
|
|
2944
|
+
destroy(): void;
|
|
2945
|
+
/** Send a chat message on a channel. Fire-and-forget (the server broadcasts). */
|
|
2946
|
+
sendMessage(input: SendChatMessageInput): void;
|
|
2947
|
+
/** Fetch historical messages for a channel. Resolves with the server response. */
|
|
2948
|
+
getHistory(input: GetChatHistoryInput): Promise<{
|
|
2949
|
+
channel: string;
|
|
2950
|
+
messages: ChatMessage[];
|
|
2951
|
+
}>;
|
|
2952
|
+
/** Broadcast a typing indicator on a channel. */
|
|
2953
|
+
sendTyping(channel: string): void;
|
|
2954
|
+
/** List the current user's channels (ordered by last activity). */
|
|
2955
|
+
listChannels(): Promise<{
|
|
2956
|
+
channels: ChatChannel[];
|
|
2957
|
+
}>;
|
|
2958
|
+
/** Mark a channel read up to `timestamp` (unix ms). */
|
|
2959
|
+
markRead(channel: string, timestamp: number): void;
|
|
2960
|
+
/** Fetch per-user read cursors for a channel. */
|
|
2961
|
+
getReadCursors(channel: string): Promise<{
|
|
2962
|
+
channel: string;
|
|
2963
|
+
cursors: ChatReadCursor[];
|
|
2964
|
+
}>;
|
|
2965
|
+
onMessage(fn: (m: ChatMessage) => void): this;
|
|
2966
|
+
onTyping(fn: (e: ChatTypingEvent) => void): this;
|
|
2967
|
+
onReadReceipt(fn: (e: ChatReadReceipt) => void): this;
|
|
2968
|
+
private enqueue;
|
|
2969
|
+
private removePending;
|
|
2970
|
+
private resolveNext;
|
|
2971
|
+
private handleStateless;
|
|
2972
|
+
}
|
|
2973
|
+
//#endregion
|
|
2974
|
+
//#region packages/provider/src/NotificationsClient.d.ts
|
|
2975
|
+
/**
|
|
2976
|
+
* Typed client for the Abracadabra notifications feature.
|
|
2977
|
+
*
|
|
2978
|
+
* Emits:
|
|
2979
|
+
* - `new` → NotificationRecord (incoming notify:new broadcast)
|
|
2980
|
+
* - `readUpdate` → NotificationReadUpdate (broadcast after mark_read / mark_all_read)
|
|
2981
|
+
*/
|
|
2982
|
+
declare class NotificationsClient extends EventEmitter {
|
|
2983
|
+
private readonly provider;
|
|
2984
|
+
private readonly responseTimeoutMs;
|
|
2985
|
+
private readonly pending;
|
|
2986
|
+
private readonly boundOnStateless;
|
|
2987
|
+
constructor(provider: ChatClientTransport, options?: {
|
|
2988
|
+
responseTimeoutMs?: number;
|
|
2989
|
+
});
|
|
2990
|
+
destroy(): void;
|
|
2991
|
+
/**
|
|
2992
|
+
* Create a notification targeting a specific recipient. Requires elevated role
|
|
2993
|
+
* (service or admin); a `server:error` event with code `forbidden` is emitted
|
|
2994
|
+
* by the underlying provider if the caller lacks permission.
|
|
2995
|
+
*/
|
|
2996
|
+
create(input: CreateNotificationInput): void;
|
|
2997
|
+
/** Fetch notification history for the current user. */
|
|
2998
|
+
fetch(input?: FetchNotificationsInput): Promise<{
|
|
2999
|
+
notifications: NotificationRecord[];
|
|
3000
|
+
}>;
|
|
3001
|
+
/** Mark a single notification, or a batch, as read. */
|
|
3002
|
+
markRead(target: {
|
|
3003
|
+
id: string;
|
|
3004
|
+
} | {
|
|
3005
|
+
ids: string[];
|
|
3006
|
+
}): void;
|
|
3007
|
+
/** Mark every notification for the current user as read. */
|
|
3008
|
+
markAllRead(): void;
|
|
3009
|
+
/** Mark all notifications generated by the given source (e.g. a chat channel) as read. */
|
|
3010
|
+
markReadBySource(sourceId: string): void;
|
|
3011
|
+
onNew(fn: (n: NotificationRecord) => void): this;
|
|
3012
|
+
onReadUpdate(fn: (u: NotificationReadUpdate) => void): this;
|
|
3013
|
+
private enqueue;
|
|
3014
|
+
private removePending;
|
|
3015
|
+
private resolveNext;
|
|
3016
|
+
private handleStateless;
|
|
3017
|
+
}
|
|
3018
|
+
//#endregion
|
|
2829
3019
|
//#region node_modules/@scure/bip39/esm/wordlists/english.d.ts
|
|
2830
3020
|
declare const wordlist: string[];
|
|
2831
3021
|
//#endregion
|
|
@@ -2893,4 +3083,4 @@ declare function wrapSeed(seed: Uint8Array, wrappingKeyBytes: Uint8Array): Promi
|
|
|
2893
3083
|
*/
|
|
2894
3084
|
declare function unwrapSeed(ciphertext: ArrayBuffer, iv: Uint8Array, wrappingKeyBytes: Uint8Array): Promise<Uint8Array>;
|
|
2895
3085
|
//#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 };
|
|
3086
|
+
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
|
@@ -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
|
+
}
|
package/src/IdentityDoc.ts
CHANGED
|
@@ -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).
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import EventEmitter from "./EventEmitter.ts";
|
|
2
|
+
import type {
|
|
3
|
+
CreateNotificationInput,
|
|
4
|
+
FetchNotificationsInput,
|
|
5
|
+
NotificationReadUpdate,
|
|
6
|
+
NotificationRecord,
|
|
7
|
+
} from "./types.ts";
|
|
8
|
+
import type { ChatClientTransport } from "./ChatClient.ts";
|
|
9
|
+
|
|
10
|
+
type PendingResolver<T> = {
|
|
11
|
+
resolve: (value: T) => void;
|
|
12
|
+
reject: (err: Error) => void;
|
|
13
|
+
timer: ReturnType<typeof setTimeout>;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const DEFAULT_TIMEOUT_MS = 10_000;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Typed client for the Abracadabra notifications feature.
|
|
20
|
+
*
|
|
21
|
+
* Emits:
|
|
22
|
+
* - `new` → NotificationRecord (incoming notify:new broadcast)
|
|
23
|
+
* - `readUpdate` → NotificationReadUpdate (broadcast after mark_read / mark_all_read)
|
|
24
|
+
*/
|
|
25
|
+
export class NotificationsClient extends EventEmitter {
|
|
26
|
+
private readonly provider: ChatClientTransport;
|
|
27
|
+
private readonly responseTimeoutMs: number;
|
|
28
|
+
private readonly pending: Map<string, PendingResolver<any>[]> = new Map();
|
|
29
|
+
private readonly boundOnStateless: (data: { payload: string }) => void;
|
|
30
|
+
|
|
31
|
+
constructor(provider: ChatClientTransport, options?: { responseTimeoutMs?: number }) {
|
|
32
|
+
super();
|
|
33
|
+
this.provider = provider;
|
|
34
|
+
this.responseTimeoutMs = options?.responseTimeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
35
|
+
this.boundOnStateless = (data) => this.handleStateless(data.payload);
|
|
36
|
+
this.provider.on("stateless", this.boundOnStateless);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
destroy(): void {
|
|
40
|
+
this.provider.off("stateless", this.boundOnStateless);
|
|
41
|
+
for (const queue of this.pending.values()) {
|
|
42
|
+
for (const p of queue) {
|
|
43
|
+
clearTimeout(p.timer);
|
|
44
|
+
p.reject(new Error("NotificationsClient destroyed"));
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
this.pending.clear();
|
|
48
|
+
this.removeAllListeners();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ── Outgoing requests ──────────────────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Create a notification targeting a specific recipient. Requires elevated role
|
|
55
|
+
* (service or admin); a `server:error` event with code `forbidden` is emitted
|
|
56
|
+
* by the underlying provider if the caller lacks permission.
|
|
57
|
+
*/
|
|
58
|
+
create(input: CreateNotificationInput): void {
|
|
59
|
+
this.provider.sendStateless(
|
|
60
|
+
JSON.stringify({
|
|
61
|
+
type: "notify:create",
|
|
62
|
+
recipient_id: input.recipient_id,
|
|
63
|
+
...(input.notification_type !== undefined ? { notification_type: input.notification_type } : {}),
|
|
64
|
+
title: input.title,
|
|
65
|
+
...(input.body !== undefined ? { body: input.body } : {}),
|
|
66
|
+
...(input.icon !== undefined ? { icon: input.icon } : {}),
|
|
67
|
+
...(input.link !== undefined ? { link: input.link } : {}),
|
|
68
|
+
...(input.source_id !== undefined ? { source_id: input.source_id } : {}),
|
|
69
|
+
}),
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Fetch notification history for the current user. */
|
|
74
|
+
fetch(input: FetchNotificationsInput = {}): Promise<{ notifications: NotificationRecord[] }> {
|
|
75
|
+
const promise = this.enqueue<{ notifications: NotificationRecord[] }>("notify:history");
|
|
76
|
+
this.provider.sendStateless(
|
|
77
|
+
JSON.stringify({
|
|
78
|
+
type: "notify:fetch",
|
|
79
|
+
...(input.before !== undefined ? { before: input.before } : {}),
|
|
80
|
+
...(input.limit !== undefined ? { limit: input.limit } : {}),
|
|
81
|
+
...(input.unread_only !== undefined ? { unread_only: input.unread_only } : {}),
|
|
82
|
+
}),
|
|
83
|
+
);
|
|
84
|
+
return promise;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Mark a single notification, or a batch, as read. */
|
|
88
|
+
markRead(target: { id: string } | { ids: string[] }): void {
|
|
89
|
+
const body: Record<string, unknown> = { type: "notify:mark_read" };
|
|
90
|
+
if ("id" in target) body.id = target.id;
|
|
91
|
+
else body.ids = target.ids;
|
|
92
|
+
this.provider.sendStateless(JSON.stringify(body));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Mark every notification for the current user as read. */
|
|
96
|
+
markAllRead(): void {
|
|
97
|
+
this.provider.sendStateless(JSON.stringify({ type: "notify:mark_all_read" }));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Mark all notifications generated by the given source (e.g. a chat channel) as read. */
|
|
101
|
+
markReadBySource(sourceId: string): void {
|
|
102
|
+
this.provider.sendStateless(
|
|
103
|
+
JSON.stringify({ type: "notify:mark_read_by_source", source_id: sourceId }),
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ── Event helpers ─────────────────────────────────────────────────────────
|
|
108
|
+
|
|
109
|
+
onNew(fn: (n: NotificationRecord) => void): this {
|
|
110
|
+
return this.on("new", fn) as this;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
onReadUpdate(fn: (u: NotificationReadUpdate) => void): this {
|
|
114
|
+
return this.on("readUpdate", fn) as this;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ── Internals ─────────────────────────────────────────────────────────────
|
|
118
|
+
|
|
119
|
+
private enqueue<T>(type: string): Promise<T> {
|
|
120
|
+
return new Promise<T>((resolve, reject) => {
|
|
121
|
+
const timer = setTimeout(() => {
|
|
122
|
+
this.removePending(type, entry);
|
|
123
|
+
reject(new Error(`NotificationsClient: timeout waiting for ${type} response`));
|
|
124
|
+
}, this.responseTimeoutMs);
|
|
125
|
+
const entry: PendingResolver<T> = { resolve, reject, timer };
|
|
126
|
+
const queue = this.pending.get(type) ?? [];
|
|
127
|
+
queue.push(entry);
|
|
128
|
+
this.pending.set(type, queue);
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
private removePending(type: string, entry: PendingResolver<any>): void {
|
|
133
|
+
const queue = this.pending.get(type);
|
|
134
|
+
if (!queue) return;
|
|
135
|
+
const idx = queue.indexOf(entry);
|
|
136
|
+
if (idx >= 0) queue.splice(idx, 1);
|
|
137
|
+
if (queue.length === 0) this.pending.delete(type);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
private resolveNext<T>(type: string, value: T): boolean {
|
|
141
|
+
const queue = this.pending.get(type);
|
|
142
|
+
if (!queue || queue.length === 0) return false;
|
|
143
|
+
const next = queue.shift()!;
|
|
144
|
+
if (queue.length === 0) this.pending.delete(type);
|
|
145
|
+
clearTimeout(next.timer);
|
|
146
|
+
next.resolve(value);
|
|
147
|
+
return true;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
private handleStateless(payload: string): void {
|
|
151
|
+
let parsed: any;
|
|
152
|
+
try {
|
|
153
|
+
parsed = JSON.parse(payload);
|
|
154
|
+
} catch {
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
const type: unknown = parsed?.type;
|
|
158
|
+
if (typeof type !== "string" || !type.startsWith("notify:")) return;
|
|
159
|
+
|
|
160
|
+
switch (type) {
|
|
161
|
+
case "notify:new": {
|
|
162
|
+
const { type: _t, ...rest } = parsed;
|
|
163
|
+
this.emit("new", rest as NotificationRecord);
|
|
164
|
+
break;
|
|
165
|
+
}
|
|
166
|
+
case "notify:history": {
|
|
167
|
+
this.resolveNext("notify:history", {
|
|
168
|
+
notifications: parsed.notifications ?? [],
|
|
169
|
+
});
|
|
170
|
+
break;
|
|
171
|
+
}
|
|
172
|
+
case "notify:read_update": {
|
|
173
|
+
const update: NotificationReadUpdate = {
|
|
174
|
+
recipient_id: parsed.recipient_id,
|
|
175
|
+
};
|
|
176
|
+
if (parsed.ids !== undefined) update.ids = parsed.ids;
|
|
177
|
+
if (parsed.all !== undefined) update.all = parsed.all;
|
|
178
|
+
this.emit("readUpdate", update);
|
|
179
|
+
break;
|
|
180
|
+
}
|
|
181
|
+
default:
|
|
182
|
+
break;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|