@abraca/dabra 1.5.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 +478 -42
- package/dist/abracadabra-provider.cjs.map +1 -1
- package/dist/abracadabra-provider.esm.js +477 -43
- package/dist/abracadabra-provider.esm.js.map +1 -1
- package/dist/index.d.ts +234 -11
- package/package.json +1 -1
- package/src/AbracadabraBaseProvider.ts +15 -0
- package/src/AbracadabraProvider.ts +56 -0
- package/src/BackgroundSyncManager.ts +44 -30
- package/src/ChatClient.ts +234 -0
- package/src/IdentityDoc.ts +11 -0
- package/src/NotificationsClient.ts +185 -0
- package/src/TreeTimestamps.ts +47 -16
- package/src/index.ts +16 -0
- package/src/types.ts +92 -0
- package/src/webrtc/DevicePairingChannel.ts +18 -6
|
@@ -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
|
+
}
|
package/src/TreeTimestamps.ts
CHANGED
|
@@ -8,47 +8,78 @@
|
|
|
8
8
|
* This propagates "last edited" timestamps to all peers via the root CRDT,
|
|
9
9
|
* without requiring any server-side changes.
|
|
10
10
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
11
|
+
* A trailing-edge throttle (default 5 s) limits writes to avoid CRDT bloat
|
|
12
|
+
* on the root doc during rapid typing.
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
import * as Y from "yjs";
|
|
16
16
|
import type { OfflineStore } from "./OfflineStore.ts";
|
|
17
17
|
|
|
18
18
|
/**
|
|
19
|
-
* Attach an observer that writes `updatedAt
|
|
20
|
-
*
|
|
21
|
-
* non-offline update.
|
|
19
|
+
* Attach an observer that writes `updatedAt` to the root doc-tree entry for
|
|
20
|
+
* `childDocId` whenever the child doc receives a non-offline update.
|
|
22
21
|
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
22
|
+
* Writes are throttled: the first qualifying update records the timestamp;
|
|
23
|
+
* a trailing-edge timer flushes it to the tree map after `throttleMs`.
|
|
24
|
+
*
|
|
25
|
+
* @param treeMap The root doc's "doc-tree" Y.Map.
|
|
26
|
+
* @param childDocId The child document's UUID (key in treeMap).
|
|
27
|
+
* @param childDoc The child Y.Doc to observe.
|
|
26
28
|
* @param offlineStore The child provider's OfflineStore (used to detect
|
|
27
29
|
* offline-replay origins and skip them). Pass null when
|
|
28
30
|
* the offline store is disabled.
|
|
29
|
-
* @
|
|
31
|
+
* @param options Optional config. `throttleMs` controls the write
|
|
32
|
+
* interval (default 5000).
|
|
33
|
+
* @returns Cleanup function — call on provider destroy. Flushes
|
|
34
|
+
* any pending write before detaching.
|
|
30
35
|
*/
|
|
31
36
|
export function attachUpdatedAtObserver(
|
|
32
37
|
treeMap: Y.Map<any>,
|
|
33
38
|
childDocId: string,
|
|
34
39
|
childDoc: Y.Doc,
|
|
35
40
|
offlineStore: OfflineStore | null,
|
|
41
|
+
options?: { throttleMs?: number },
|
|
36
42
|
): () => void {
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
43
|
+
const throttleMs = options?.throttleMs ?? 5000;
|
|
44
|
+
|
|
45
|
+
let latestTs = 0;
|
|
46
|
+
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
47
|
+
|
|
48
|
+
function flush(): void {
|
|
49
|
+
if (latestTs === 0) return;
|
|
50
|
+
const ts = latestTs;
|
|
51
|
+
latestTs = 0;
|
|
52
|
+
timer = null;
|
|
41
53
|
|
|
42
|
-
// Update the root tree entry (no-op if the entry doesn't exist).
|
|
43
54
|
const raw = treeMap.get(childDocId);
|
|
44
55
|
if (!raw) return;
|
|
45
56
|
|
|
46
57
|
// Guard: if the entry is a nested Y.Map (possible after Yrs
|
|
47
58
|
// document compaction), convert to plain object so spread works.
|
|
48
59
|
const entry = raw instanceof Y.Map ? (raw as any).toJSON() : raw;
|
|
49
|
-
treeMap.set(childDocId, { ...entry, updatedAt:
|
|
60
|
+
treeMap.set(childDocId, { ...entry, updatedAt: ts });
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function handler(_update: Uint8Array, origin: unknown): void {
|
|
64
|
+
// Skip updates replayed from the local offline store — they represent
|
|
65
|
+
// content that was already "seen" and shouldn't advance updatedAt.
|
|
66
|
+
if (offlineStore !== null && origin === offlineStore) return;
|
|
67
|
+
|
|
68
|
+
latestTs = Date.now();
|
|
69
|
+
|
|
70
|
+
// Schedule a trailing-edge flush if none is pending.
|
|
71
|
+
if (timer === null) {
|
|
72
|
+
timer = setTimeout(flush, throttleMs);
|
|
73
|
+
}
|
|
50
74
|
}
|
|
51
75
|
|
|
52
76
|
childDoc.on("update", handler);
|
|
53
|
-
|
|
77
|
+
|
|
78
|
+
return () => {
|
|
79
|
+
childDoc.off("update", handler);
|
|
80
|
+
if (timer !== null) {
|
|
81
|
+
clearTimeout(timer);
|
|
82
|
+
flush(); // persist the last pending timestamp
|
|
83
|
+
}
|
|
84
|
+
};
|
|
54
85
|
}
|
package/src/index.ts
CHANGED
|
@@ -36,6 +36,22 @@ export type {
|
|
|
36
36
|
DeviceTier,
|
|
37
37
|
} from "./IdentityDoc.ts";
|
|
38
38
|
export { DeviceRegistrationService } from "./DeviceRegistrationService.ts";
|
|
39
|
+
export { ChatClient } from "./ChatClient.ts";
|
|
40
|
+
export type { ChatClientTransport } from "./ChatClient.ts";
|
|
41
|
+
export { NotificationsClient } from "./NotificationsClient.ts";
|
|
42
|
+
export type {
|
|
43
|
+
ChatMessage,
|
|
44
|
+
ChatChannel,
|
|
45
|
+
ChatTypingEvent,
|
|
46
|
+
ChatReadReceipt,
|
|
47
|
+
ChatReadCursor,
|
|
48
|
+
SendChatMessageInput,
|
|
49
|
+
GetChatHistoryInput,
|
|
50
|
+
NotificationRecord,
|
|
51
|
+
NotificationReadUpdate,
|
|
52
|
+
CreateNotificationInput,
|
|
53
|
+
FetchNotificationsInput,
|
|
54
|
+
} from "./types.ts";
|
|
39
55
|
export {
|
|
40
56
|
generateMnemonic,
|
|
41
57
|
validateMnemonic,
|
package/src/types.ts
CHANGED
|
@@ -124,6 +124,12 @@ export type onStatelessParameters = {
|
|
|
124
124
|
payload: string;
|
|
125
125
|
};
|
|
126
126
|
|
|
127
|
+
export type onServerErrorParameters = {
|
|
128
|
+
source: string;
|
|
129
|
+
code: string;
|
|
130
|
+
message: string;
|
|
131
|
+
};
|
|
132
|
+
|
|
127
133
|
export type StatesArray = { clientId: number; [key: string | number]: any }[];
|
|
128
134
|
|
|
129
135
|
// ── Abracadabra extensions ────────────────────────────────────────────────────
|
|
@@ -342,3 +348,89 @@ export interface DocEncryptionInfo {
|
|
|
342
348
|
effective_mode: "none" | "cse" | "e2e";
|
|
343
349
|
inherited_from?: string;
|
|
344
350
|
}
|
|
351
|
+
|
|
352
|
+
// ── Chat (stateless channel) ─────────────────────────────────────────────────
|
|
353
|
+
|
|
354
|
+
export interface ChatMessage {
|
|
355
|
+
id: string;
|
|
356
|
+
channel: string;
|
|
357
|
+
sender_id: string;
|
|
358
|
+
sender_name?: string | null;
|
|
359
|
+
content: string;
|
|
360
|
+
created_at: number;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
export interface ChatChannel {
|
|
364
|
+
channel: string;
|
|
365
|
+
label?: string | null;
|
|
366
|
+
last_message: ChatMessage;
|
|
367
|
+
unread_count: number;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
export interface ChatTypingEvent {
|
|
371
|
+
channel: string;
|
|
372
|
+
sender_id: string;
|
|
373
|
+
sender_name?: string | null;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
export interface ChatReadReceipt {
|
|
377
|
+
channel: string;
|
|
378
|
+
user_id: string;
|
|
379
|
+
last_read_at: number;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
export interface ChatReadCursor {
|
|
383
|
+
user_id: string;
|
|
384
|
+
last_read_at: number;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
export interface SendChatMessageInput {
|
|
388
|
+
channel: string;
|
|
389
|
+
content: string;
|
|
390
|
+
sender_name?: string;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
export interface GetChatHistoryInput {
|
|
394
|
+
channel: string;
|
|
395
|
+
before?: number;
|
|
396
|
+
limit?: number;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// ── Notifications (stateless channel) ────────────────────────────────────────
|
|
400
|
+
|
|
401
|
+
export interface NotificationRecord {
|
|
402
|
+
id: string;
|
|
403
|
+
recipient_id: string;
|
|
404
|
+
notification_type: string;
|
|
405
|
+
title: string;
|
|
406
|
+
body: string;
|
|
407
|
+
icon?: string | null;
|
|
408
|
+
link?: string | null;
|
|
409
|
+
source_id?: string | null;
|
|
410
|
+
read: boolean;
|
|
411
|
+
created_at: number;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
export interface NotificationReadUpdate {
|
|
415
|
+
/** Present when a specific set of notifications was marked read. */
|
|
416
|
+
ids?: string[];
|
|
417
|
+
/** Present when mark_all_read was called. */
|
|
418
|
+
all?: boolean;
|
|
419
|
+
recipient_id: string;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
export interface CreateNotificationInput {
|
|
423
|
+
recipient_id: string;
|
|
424
|
+
notification_type?: string;
|
|
425
|
+
title: string;
|
|
426
|
+
body?: string;
|
|
427
|
+
icon?: string;
|
|
428
|
+
link?: string;
|
|
429
|
+
source_id?: string;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
export interface FetchNotificationsInput {
|
|
433
|
+
before?: number;
|
|
434
|
+
limit?: number;
|
|
435
|
+
unread_only?: boolean;
|
|
436
|
+
}
|