@pmate/chat-sdk 0.9.2 → 0.9.3
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/README.md +32 -0
- package/lib/runtime/chatClient.d.ts +4 -0
- package/lib/runtime/chatClient.js +11 -1
- package/lib/runtime/index.d.ts +2 -0
- package/lib/runtime/index.js +2 -0
- package/lib/runtime/notification.d.ts +28 -0
- package/lib/runtime/notification.js +68 -0
- package/lib/runtime/protocol.d.ts +1 -0
- package/lib/runtime/protocol.js +1 -0
- package/lib/runtime/readIndex.d.ts +78 -0
- package/lib/runtime/readIndex.js +180 -0
- package/package.json +19 -17
package/README.md
CHANGED
|
@@ -40,3 +40,35 @@ A dropped socket (including a network change or a suspended laptop) previously r
|
|
|
40
40
|
`client.watchConnection(handler)` immediately reports the current state and returns an unsubscribe function. States are `connecting`, `reconnecting`, `authenticating`, `connected`, `authentication_required`, and `closed`. Only `connected` means the Hub acknowledged authentication. A rejected authentication stops retries; supply a fresh login session using a new client. `close()` cancels retries, heartbeat timers and browser wake listeners.
|
|
41
41
|
|
|
42
42
|
Messages sent without an acknowledgement are rejected on disconnect and are **not replayed**: delivery is uncertain, so automatic retries could duplicate them. Callers should preserve drafts, display a reconnecting state, and refresh message history after recovery. No durable offline outbox is provided. An initial `connect()` wait times out after 30 seconds, but recovery continues until explicitly closed; retain the client and observe its connection state.
|
|
43
|
+
|
|
44
|
+
## Versioned Markdown and chart content
|
|
45
|
+
|
|
46
|
+
### Background
|
|
47
|
+
|
|
48
|
+
A reply may need ordered prose, a trend chart, and a conclusion. The shared
|
|
49
|
+
content contract carries those blocks inside the existing `contentKind: "text"`
|
|
50
|
+
envelope. `text` is a deterministic projection for older clients and search;
|
|
51
|
+
new clients render only explicitly versioned content, never guess Markdown.
|
|
52
|
+
|
|
53
|
+
```ts
|
|
54
|
+
import { createRichMessageContent, readMessageContent } from "@pmate/chat-sdk/runtime"
|
|
55
|
+
const content = createRichMessageContent([
|
|
56
|
+
{ id: "intro", type: "markdown", text: "## Analysis\n\n**Summary**" },
|
|
57
|
+
])
|
|
58
|
+
// sendChat(peer, { kind: "message", threadKind: "dm", contentKind: "text",
|
|
59
|
+
// content: { ...content, appId } })
|
|
60
|
+
const result = readMessageContent(content)
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
`ChartBlock` supports line/bar, numeric series, `null` gaps and an evidence record
|
|
64
|
+
(window, timezone, definition, source, query ID and completeness). Never fill
|
|
65
|
+
missing coverage with zero or sum daily UV for a window total. HTML/scripts are
|
|
66
|
+
not part of this protocol. Consumers must restrict URL schemes and render with
|
|
67
|
+
safe Markdown components. Limits are 64 KiB, 20 blocks, 16,000 projection
|
|
68
|
+
characters, 100 points and four series per chart. Invalid versions or content
|
|
69
|
+
are explicit errors.
|
|
70
|
+
|
|
71
|
+
`applyContentUpdate` accepts sequential revisions, ignores duplicate revisions,
|
|
72
|
+
and withholds incomplete charts. A revision gap requires fetching a snapshot.
|
|
73
|
+
This reducer does not itself create a network stream; the sender must provide
|
|
74
|
+
updates and the receiver must preserve its existing identity/authorization checks.
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type OnlineNotificationHandler } from "./notification.js";
|
|
1
2
|
import { type ChatPresence } from "./presence.js";
|
|
2
3
|
import { type ChatUpdate } from "./updates.js";
|
|
3
4
|
import { ChatActivityPublisher, type ActivityTarget, type ChatActivity, type ChatActivityState, type ChatActivityUpdate } from "./activity.js";
|
|
@@ -8,6 +9,8 @@ import { type ChatBody, type ChatRuntimeMessage, type RealtimeClient } from "./p
|
|
|
8
9
|
export type ChatConnectionState = "connecting" | "reconnecting" | "authenticating" | "connected" | "authentication_required" | "closed";
|
|
9
10
|
export type ChatClientOptions = {
|
|
10
11
|
appId: string;
|
|
12
|
+
clientInstanceId?: string;
|
|
13
|
+
onNotification?: OnlineNotificationHandler;
|
|
11
14
|
profileId?: string;
|
|
12
15
|
botKey?: PmateBotKeyFile | string | unknown;
|
|
13
16
|
session?: BotLoginSession;
|
|
@@ -24,6 +27,7 @@ export type ChatClientOptions = {
|
|
|
24
27
|
export declare class ChatClient {
|
|
25
28
|
private readonly options;
|
|
26
29
|
private readonly instanceId;
|
|
30
|
+
private readonly notifications;
|
|
27
31
|
private presence?;
|
|
28
32
|
private presenceProfiles;
|
|
29
33
|
private presenceHandlers;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { OnlineNotificationReceiver } from "./notification.js";
|
|
1
2
|
import { ChatPresenceClient } from "./presence.js";
|
|
2
3
|
import { parseChatUpdate } from "./updates.js";
|
|
3
4
|
import { ChatActivityStore, ChatActivityPublisher, createActivityMessage, parseChatActivity } from "./activity.js";
|
|
@@ -9,7 +10,8 @@ const DEFAULT_AUTH_API_BASE_URL = "https://auth-api-v2.pmate.chat";
|
|
|
9
10
|
const DEFAULT_ACK_TIMEOUT_MS = 5_000;
|
|
10
11
|
export class ChatClient {
|
|
11
12
|
options;
|
|
12
|
-
instanceId
|
|
13
|
+
instanceId;
|
|
14
|
+
notifications;
|
|
13
15
|
presence;
|
|
14
16
|
presenceProfiles = [];
|
|
15
17
|
presenceHandlers = new Set();
|
|
@@ -33,6 +35,9 @@ export class ChatClient {
|
|
|
33
35
|
}
|
|
34
36
|
constructor(options) {
|
|
35
37
|
this.options = options;
|
|
38
|
+
this.instanceId = options.clientInstanceId ?? crypto.randomUUID();
|
|
39
|
+
this.notifications = new OnlineNotificationReceiver({ appId: options.appId, profileId: () => this.profileId(),
|
|
40
|
+
send: message => this.requireClient().sendMsg(message), handler: options.onNotification });
|
|
36
41
|
if (options.botKey) {
|
|
37
42
|
this.botKey = parsePmateBotKeyJson(options.botKey);
|
|
38
43
|
}
|
|
@@ -294,6 +299,11 @@ export class ChatClient {
|
|
|
294
299
|
return ack;
|
|
295
300
|
}
|
|
296
301
|
handleIncoming(message) {
|
|
302
|
+
if (message.opcode === ChatMsgOp.NOTIFICATION) {
|
|
303
|
+
if (this.connection === "connected")
|
|
304
|
+
void this.notifications.receive(message).catch(error => this.options.onActivityError?.(error));
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
297
307
|
const update = parseChatUpdate(message, this.options.appId);
|
|
298
308
|
if (update) {
|
|
299
309
|
for (const handler of this.updateHandlers)
|
package/lib/runtime/index.d.ts
CHANGED
|
@@ -4,6 +4,7 @@ export * from "./chatClient.js";
|
|
|
4
4
|
export * from "./protocol.js";
|
|
5
5
|
export * from "./content.js";
|
|
6
6
|
export * from "./readerClient.js";
|
|
7
|
+
export * from "./readIndex.js";
|
|
7
8
|
export * from "./thread.js";
|
|
8
9
|
export * from "./updater.js";
|
|
9
10
|
export * from "./updates.js";
|
|
@@ -11,3 +12,4 @@ export * from "./activity.js";
|
|
|
11
12
|
export * from "./call.js";
|
|
12
13
|
export * from "./presence.js";
|
|
13
14
|
export * from "./meeting.js";
|
|
15
|
+
export * from "./notification.js";
|
package/lib/runtime/index.js
CHANGED
|
@@ -4,6 +4,7 @@ export * from "./chatClient.js";
|
|
|
4
4
|
export * from "./protocol.js";
|
|
5
5
|
export * from "./content.js";
|
|
6
6
|
export * from "./readerClient.js";
|
|
7
|
+
export * from "./readIndex.js";
|
|
7
8
|
export * from "./thread.js";
|
|
8
9
|
export * from "./updater.js";
|
|
9
10
|
export * from "./updates.js";
|
|
@@ -11,3 +12,4 @@ export * from "./activity.js";
|
|
|
11
12
|
export * from "./call.js";
|
|
12
13
|
export * from "./presence.js";
|
|
13
14
|
export * from "./meeting.js";
|
|
15
|
+
export * from "./notification.js";
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { type ChatRuntimeMessage } from "./protocol.js";
|
|
2
|
+
export type OnlineNotification = {
|
|
3
|
+
kind: "delivery";
|
|
4
|
+
attemptId: string;
|
|
5
|
+
notificationId: string;
|
|
6
|
+
title?: string;
|
|
7
|
+
body?: string;
|
|
8
|
+
data: Record<string, unknown>;
|
|
9
|
+
createdAtMs?: number;
|
|
10
|
+
expiresAtMs?: number;
|
|
11
|
+
};
|
|
12
|
+
export type NotificationHandling = "displayed" | "suppressed";
|
|
13
|
+
export type OnlineNotificationHandler = (notification: OnlineNotification) => Promise<NotificationHandling>;
|
|
14
|
+
/** Device-local receipt and deduplication; only the host can confirm presentation. */
|
|
15
|
+
export declare class OnlineNotificationReceiver {
|
|
16
|
+
private readonly options;
|
|
17
|
+
private readonly handled;
|
|
18
|
+
private readonly pending;
|
|
19
|
+
constructor(options: {
|
|
20
|
+
appId: string;
|
|
21
|
+
profileId: () => string;
|
|
22
|
+
send: (message: ChatRuntimeMessage) => Promise<void>;
|
|
23
|
+
handler?: OnlineNotificationHandler;
|
|
24
|
+
now?: () => number;
|
|
25
|
+
});
|
|
26
|
+
receive(message: ChatRuntimeMessage): Promise<void>;
|
|
27
|
+
private now;
|
|
28
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { ChatMsgOp, createRuntimeMsg } from "./protocol.js";
|
|
2
|
+
/** Device-local receipt and deduplication; only the host can confirm presentation. */
|
|
3
|
+
export class OnlineNotificationReceiver {
|
|
4
|
+
options;
|
|
5
|
+
handled = new Map();
|
|
6
|
+
pending = new Map();
|
|
7
|
+
constructor(options) {
|
|
8
|
+
this.options = options;
|
|
9
|
+
}
|
|
10
|
+
async receive(message) {
|
|
11
|
+
if (message.opcode !== ChatMsgOp.NOTIFICATION || message.from !== "@hub/notifications" || message.to !== this.options.profileId())
|
|
12
|
+
return;
|
|
13
|
+
const body = message.body;
|
|
14
|
+
if (!body || body.kind !== "delivery" || typeof body.attemptId !== "string" || !body.attemptId
|
|
15
|
+
|| typeof body.notificationId !== "string" || !body.notificationId)
|
|
16
|
+
return;
|
|
17
|
+
const ack = (status, code) => this.options.send(createRuntimeMsg(this.options.profileId(), "@hub/notifications", ChatMsgOp.NOTIFICATION, {
|
|
18
|
+
kind: "ack", attemptId: body.attemptId, notificationId: body.notificationId, status,
|
|
19
|
+
displayed: status === "displayed", code, receivedAtMs: this.now(),
|
|
20
|
+
}));
|
|
21
|
+
if (!body.data || typeof body.data !== "object" || body.data.appId !== this.options.appId) {
|
|
22
|
+
await ack("error", "notification_app_mismatch");
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
if (body.expiresAtMs !== undefined && (!Number.isFinite(body.expiresAtMs) || body.expiresAtMs <= this.now())) {
|
|
26
|
+
await ack("suppressed", "notification_expired");
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
if ((body.title !== undefined && typeof body.title !== "string") || (body.body !== undefined && typeof body.body !== "string")) {
|
|
30
|
+
await ack("error", "notification_invalid");
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
await ack("received");
|
|
34
|
+
for (const [id, until] of this.handled)
|
|
35
|
+
if (until <= this.now())
|
|
36
|
+
this.handled.delete(id);
|
|
37
|
+
if (this.handled.has(body.notificationId)) {
|
|
38
|
+
await ack("suppressed", "notification_duplicate");
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
let operation = this.pending.get(body.notificationId);
|
|
42
|
+
if (!operation) {
|
|
43
|
+
operation = Promise.resolve().then(async () => {
|
|
44
|
+
if (!this.options.handler)
|
|
45
|
+
throw new Error("Notification host unavailable");
|
|
46
|
+
const status = await this.options.handler(structuredClone(body));
|
|
47
|
+
if (status !== "displayed" && status !== "suppressed")
|
|
48
|
+
throw new Error("Invalid notification handling result");
|
|
49
|
+
if (this.handled.size >= 10_000)
|
|
50
|
+
this.handled.delete(this.handled.keys().next().value);
|
|
51
|
+
this.handled.set(body.notificationId, Math.max(this.now() + 120_000, Math.min(body.expiresAtMs ?? this.now() + 86_400_000, this.now() + 86_400_000)));
|
|
52
|
+
return status;
|
|
53
|
+
});
|
|
54
|
+
this.pending.set(body.notificationId, operation);
|
|
55
|
+
}
|
|
56
|
+
try {
|
|
57
|
+
await ack(await operation);
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
await ack("error", "notification_handling_failed");
|
|
61
|
+
}
|
|
62
|
+
finally {
|
|
63
|
+
if (this.pending.get(body.notificationId) === operation)
|
|
64
|
+
this.pending.delete(body.notificationId);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
now() { return this.options.now?.() ?? Date.now(); }
|
|
68
|
+
}
|
package/lib/runtime/protocol.js
CHANGED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/** Versioned, persistable reading projection shared by offline hosts. */
|
|
2
|
+
export type IndexedReadMessage = {
|
|
3
|
+
id: string;
|
|
4
|
+
seq: number;
|
|
5
|
+
from: string;
|
|
6
|
+
recipients: string[];
|
|
7
|
+
mentions: string[];
|
|
8
|
+
readPolicy: "visible" | "explicit";
|
|
9
|
+
recalled: boolean;
|
|
10
|
+
/** Cached payload is retained so an offline reload can render the message. */
|
|
11
|
+
payload: unknown;
|
|
12
|
+
};
|
|
13
|
+
export type ReadingOperation = {
|
|
14
|
+
operationId: string;
|
|
15
|
+
threadId: string;
|
|
16
|
+
readThroughSeq: number;
|
|
17
|
+
readThroughMessageId?: string;
|
|
18
|
+
explicitReadMessageIds: string[];
|
|
19
|
+
};
|
|
20
|
+
export type IndexedReadThread = {
|
|
21
|
+
id: string;
|
|
22
|
+
kind: "dm" | "group";
|
|
23
|
+
messages: IndexedReadMessage[];
|
|
24
|
+
readThroughSeq: number;
|
|
25
|
+
explicitReadMessageIds: string[];
|
|
26
|
+
};
|
|
27
|
+
export type ReadIndexState = {
|
|
28
|
+
schemaVersion: 1;
|
|
29
|
+
appId: string;
|
|
30
|
+
profileId: string;
|
|
31
|
+
syncCursor: string;
|
|
32
|
+
threads: IndexedReadThread[];
|
|
33
|
+
pending: ReadingOperation[];
|
|
34
|
+
};
|
|
35
|
+
export type ReadSnapshot = Omit<ReadIndexState, "pending">;
|
|
36
|
+
export type ReadChange = {
|
|
37
|
+
eventId: string;
|
|
38
|
+
appId: string;
|
|
39
|
+
profileId: string;
|
|
40
|
+
previousCursor: string;
|
|
41
|
+
syncCursor: string;
|
|
42
|
+
thread: IndexedReadThread;
|
|
43
|
+
};
|
|
44
|
+
export declare class ReadSyncGap extends Error {
|
|
45
|
+
readonly cursor: string;
|
|
46
|
+
constructor(cursor: string);
|
|
47
|
+
}
|
|
48
|
+
export declare function createReadIndex(appId: string, profileId: string): ReadIndexState;
|
|
49
|
+
/** A snapshot is authoritative for server state, never for unsent local operations. */
|
|
50
|
+
export declare function installReadSnapshot(current: ReadIndexState, snapshot: ReadSnapshot): ReadIndexState;
|
|
51
|
+
/** Cursor positions are opaque; only exact predecessor matching proves continuity. */
|
|
52
|
+
export declare function applyReadChange(current: ReadIndexState, change: ReadChange): ReadIndexState;
|
|
53
|
+
/** Called only for a message the visible viewport actually reached. */
|
|
54
|
+
export declare function recordVisibleRead(current: ReadIndexState, threadId: string, messageId: string, operationId: string): ReadIndexState;
|
|
55
|
+
export declare function recordExplicitRead(current: ReadIndexState, threadId: string, messageId: string, operationId: string): ReadIndexState;
|
|
56
|
+
/** Coalescing does not mutate outbox IDs: remove operations only after projection confirms them. */
|
|
57
|
+
export declare function coalesceReadOperations(current: ReadIndexState): (ReadingOperation & {
|
|
58
|
+
operationIds: string[];
|
|
59
|
+
})[];
|
|
60
|
+
/** Counts are over the indexed message coverage, not an estimate of missing history. */
|
|
61
|
+
export declare function unreadInThread(state: ReadIndexState, threadId: string): {
|
|
62
|
+
unreadMessageCount: number;
|
|
63
|
+
unreadMentionCount: number;
|
|
64
|
+
badgeCount: number;
|
|
65
|
+
};
|
|
66
|
+
export interface ReadIndexPersistence {
|
|
67
|
+
load(appId: string, profileId: string): Promise<ReadIndexState | undefined>;
|
|
68
|
+
/** Transform and persist atomically; UI publishes only the committed result. */
|
|
69
|
+
update(appId: string, profileId: string, transform: (current: ReadIndexState) => ReadIndexState): Promise<ReadIndexState>;
|
|
70
|
+
}
|
|
71
|
+
/** IndexedDB transactions serialize tabs, and retain offline outbox across process restarts. */
|
|
72
|
+
export declare class IndexedDbReadIndex implements ReadIndexPersistence {
|
|
73
|
+
private readonly database;
|
|
74
|
+
constructor(factory?: IDBFactory, name?: string);
|
|
75
|
+
load(appId: string, profileId: string): Promise<ReadIndexState | undefined>;
|
|
76
|
+
update(appId: string, profileId: string, transform: (current: ReadIndexState) => ReadIndexState): Promise<ReadIndexState>;
|
|
77
|
+
close(): Promise<void>;
|
|
78
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
export class ReadSyncGap extends Error {
|
|
2
|
+
cursor;
|
|
3
|
+
constructor(cursor) {
|
|
4
|
+
super("READ_SYNC_GAP");
|
|
5
|
+
this.cursor = cursor;
|
|
6
|
+
}
|
|
7
|
+
}
|
|
8
|
+
export function createReadIndex(appId, profileId) {
|
|
9
|
+
return { schemaVersion: 1, appId, profileId, syncCursor: "0", threads: [], pending: [] };
|
|
10
|
+
}
|
|
11
|
+
function validateThread(thread) {
|
|
12
|
+
if (!thread.id || !["dm", "group"].includes(thread.kind) || !Number.isSafeInteger(thread.readThroughSeq) || thread.readThroughSeq < 0)
|
|
13
|
+
throw new Error("INVALID_READ_THREAD");
|
|
14
|
+
const ids = new Set(), positions = new Set();
|
|
15
|
+
for (const message of thread.messages) {
|
|
16
|
+
if (!message.id || !Number.isSafeInteger(message.seq) || message.seq < 1 || ids.has(message.id) || positions.has(message.seq)
|
|
17
|
+
|| !["visible", "explicit"].includes(message.readPolicy))
|
|
18
|
+
throw new Error("INVALID_READ_MESSAGE");
|
|
19
|
+
ids.add(message.id);
|
|
20
|
+
positions.add(message.seq);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
function cloneThread(thread) {
|
|
24
|
+
validateThread(thread);
|
|
25
|
+
return structuredClone({ ...thread, messages: [...thread.messages].sort((a, b) => a.seq - b.seq), explicitReadMessageIds: [...new Set(thread.explicitReadMessageIds)] });
|
|
26
|
+
}
|
|
27
|
+
function applyOperation(thread, operation) {
|
|
28
|
+
return { ...thread, readThroughSeq: Math.max(thread.readThroughSeq, operation.readThroughSeq),
|
|
29
|
+
explicitReadMessageIds: [...new Set([...thread.explicitReadMessageIds, ...operation.explicitReadMessageIds])] };
|
|
30
|
+
}
|
|
31
|
+
function covered(thread, operation) {
|
|
32
|
+
return thread.readThroughSeq >= operation.readThroughSeq
|
|
33
|
+
&& operation.explicitReadMessageIds.every(id => thread.explicitReadMessageIds.includes(id));
|
|
34
|
+
}
|
|
35
|
+
/** A snapshot is authoritative for server state, never for unsent local operations. */
|
|
36
|
+
export function installReadSnapshot(current, snapshot) {
|
|
37
|
+
if (snapshot.schemaVersion !== 1 || snapshot.appId !== current.appId || snapshot.profileId !== current.profileId)
|
|
38
|
+
throw new Error("READ_IDENTITY_MISMATCH");
|
|
39
|
+
const threads = snapshot.threads.map(cloneThread);
|
|
40
|
+
if (new Set(threads.map(t => t.id)).size !== threads.length)
|
|
41
|
+
throw new Error("DUPLICATE_READ_THREAD");
|
|
42
|
+
const byId = new Map(threads.map(thread => [thread.id, thread]));
|
|
43
|
+
// Keep orphaned operations as well: the server must reject inaccessible updates explicitly.
|
|
44
|
+
const pending = current.pending.filter(operation => {
|
|
45
|
+
const thread = byId.get(operation.threadId);
|
|
46
|
+
return !thread || !covered(thread, operation);
|
|
47
|
+
});
|
|
48
|
+
for (const operation of pending) {
|
|
49
|
+
const thread = byId.get(operation.threadId);
|
|
50
|
+
if (thread)
|
|
51
|
+
byId.set(thread.id, applyOperation(thread, operation));
|
|
52
|
+
}
|
|
53
|
+
for (const previous of current.threads) {
|
|
54
|
+
const thread = byId.get(previous.id);
|
|
55
|
+
if (thread)
|
|
56
|
+
byId.set(thread.id, { ...thread, readThroughSeq: Math.max(thread.readThroughSeq, previous.readThroughSeq),
|
|
57
|
+
explicitReadMessageIds: [...new Set([...thread.explicitReadMessageIds, ...previous.explicitReadMessageIds])] });
|
|
58
|
+
}
|
|
59
|
+
return { ...snapshot, threads: [...byId.values()], pending };
|
|
60
|
+
}
|
|
61
|
+
/** Cursor positions are opaque; only exact predecessor matching proves continuity. */
|
|
62
|
+
export function applyReadChange(current, change) {
|
|
63
|
+
if (change.appId !== current.appId || change.profileId !== current.profileId)
|
|
64
|
+
throw new Error("READ_IDENTITY_MISMATCH");
|
|
65
|
+
if (change.syncCursor === current.syncCursor)
|
|
66
|
+
return current;
|
|
67
|
+
if (change.previousCursor !== current.syncCursor)
|
|
68
|
+
throw new ReadSyncGap(current.syncCursor);
|
|
69
|
+
const incoming = cloneThread(change.thread);
|
|
70
|
+
const pending = current.pending.filter(operation => operation.threadId !== incoming.id || !covered(incoming, operation));
|
|
71
|
+
const previous = current.threads.find(thread => thread.id === incoming.id);
|
|
72
|
+
const merged = previous ? { ...incoming, readThroughSeq: Math.max(incoming.readThroughSeq, previous.readThroughSeq),
|
|
73
|
+
explicitReadMessageIds: [...new Set([...incoming.explicitReadMessageIds, ...previous.explicitReadMessageIds])] } : incoming;
|
|
74
|
+
const projected = pending.filter(operation => operation.threadId === incoming.id).reduce(applyOperation, merged);
|
|
75
|
+
return { ...current, syncCursor: change.syncCursor, pending, threads: [...current.threads.filter(t => t.id !== incoming.id), projected] };
|
|
76
|
+
}
|
|
77
|
+
/** Called only for a message the visible viewport actually reached. */
|
|
78
|
+
export function recordVisibleRead(current, threadId, messageId, operationId) {
|
|
79
|
+
const thread = current.threads.find(t => t.id === threadId);
|
|
80
|
+
const message = thread?.messages.find(m => m.id === messageId);
|
|
81
|
+
if (!thread || !message || message.readPolicy !== "visible")
|
|
82
|
+
throw new Error("INVALID_VISIBLE_READ");
|
|
83
|
+
if (message.seq <= thread.readThroughSeq)
|
|
84
|
+
return current;
|
|
85
|
+
return enqueue(current, { operationId, threadId, readThroughSeq: message.seq, readThroughMessageId: message.id, explicitReadMessageIds: [] });
|
|
86
|
+
}
|
|
87
|
+
export function recordExplicitRead(current, threadId, messageId, operationId) {
|
|
88
|
+
const thread = current.threads.find(t => t.id === threadId);
|
|
89
|
+
const message = thread?.messages.find(m => m.id === messageId);
|
|
90
|
+
if (!thread || !message || message.readPolicy !== "explicit")
|
|
91
|
+
throw new Error("INVALID_EXPLICIT_READ");
|
|
92
|
+
if (thread.explicitReadMessageIds.includes(messageId))
|
|
93
|
+
return current;
|
|
94
|
+
return enqueue(current, { operationId, threadId, readThroughSeq: 0, explicitReadMessageIds: [messageId] });
|
|
95
|
+
}
|
|
96
|
+
function enqueue(current, operation) {
|
|
97
|
+
if (!operation.operationId || current.pending.some(p => p.operationId === operation.operationId))
|
|
98
|
+
throw new Error("INVALID_READ_OPERATION_ID");
|
|
99
|
+
return { ...current, pending: [...current.pending, operation], threads: current.threads.map(thread => thread.id === operation.threadId ? applyOperation(thread, operation) : thread) };
|
|
100
|
+
}
|
|
101
|
+
/** Coalescing does not mutate outbox IDs: remove operations only after projection confirms them. */
|
|
102
|
+
export function coalesceReadOperations(current) {
|
|
103
|
+
const batches = new Map();
|
|
104
|
+
for (const operation of current.pending) {
|
|
105
|
+
const batch = batches.get(operation.threadId);
|
|
106
|
+
if (!batch) {
|
|
107
|
+
batches.set(operation.threadId, { ...structuredClone(operation), operationIds: [operation.operationId] });
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
batch.operationIds.push(operation.operationId);
|
|
111
|
+
if (operation.readThroughSeq > batch.readThroughSeq) {
|
|
112
|
+
batch.readThroughSeq = operation.readThroughSeq;
|
|
113
|
+
batch.readThroughMessageId = operation.readThroughMessageId;
|
|
114
|
+
}
|
|
115
|
+
batch.explicitReadMessageIds = [...new Set([...batch.explicitReadMessageIds, ...operation.explicitReadMessageIds])];
|
|
116
|
+
}
|
|
117
|
+
return [...batches.values()];
|
|
118
|
+
}
|
|
119
|
+
/** Counts are over the indexed message coverage, not an estimate of missing history. */
|
|
120
|
+
export function unreadInThread(state, threadId) {
|
|
121
|
+
const thread = state.threads.find(t => t.id === threadId);
|
|
122
|
+
if (!thread)
|
|
123
|
+
throw new Error("UNKNOWN_READ_THREAD");
|
|
124
|
+
const explicit = new Set(thread.explicitReadMessageIds);
|
|
125
|
+
const messages = thread.messages.filter(message => !message.recalled && message.from !== state.profileId && message.recipients.includes(state.profileId)
|
|
126
|
+
&& (message.readPolicy === "explicit" ? !explicit.has(message.id) : message.seq > thread.readThroughSeq));
|
|
127
|
+
const unreadMessageCount = messages.length;
|
|
128
|
+
const unreadMentionCount = messages.filter(message => message.mentions.includes(state.profileId)).length;
|
|
129
|
+
return { unreadMessageCount, unreadMentionCount, badgeCount: thread.kind === "group" ? unreadMentionCount : unreadMessageCount };
|
|
130
|
+
}
|
|
131
|
+
/** IndexedDB transactions serialize tabs, and retain offline outbox across process restarts. */
|
|
132
|
+
export class IndexedDbReadIndex {
|
|
133
|
+
database;
|
|
134
|
+
constructor(factory = globalThis.indexedDB, name = "pmate-social-read-index") {
|
|
135
|
+
if (!factory)
|
|
136
|
+
throw new Error("READ_STORAGE_UNAVAILABLE");
|
|
137
|
+
this.database = new Promise((resolve, reject) => {
|
|
138
|
+
const request = factory.open(name, 1);
|
|
139
|
+
request.onupgradeneeded = () => { request.result.createObjectStore("profiles"); };
|
|
140
|
+
request.onsuccess = () => { request.result.onversionchange = () => request.result.close(); resolve(request.result); };
|
|
141
|
+
request.onerror = () => reject(request.error);
|
|
142
|
+
request.onblocked = () => reject(new Error("READ_STORAGE_BLOCKED"));
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
async load(appId, profileId) {
|
|
146
|
+
const database = await this.database;
|
|
147
|
+
return new Promise((resolve, reject) => {
|
|
148
|
+
const transaction = database.transaction("profiles", "readonly");
|
|
149
|
+
const request = transaction.objectStore("profiles").get([appId, profileId]);
|
|
150
|
+
transaction.oncomplete = () => resolve(request.result);
|
|
151
|
+
transaction.onerror = () => reject(transaction.error);
|
|
152
|
+
transaction.onabort = () => reject(transaction.error ?? new Error("READ_STORAGE_ABORTED"));
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
async update(appId, profileId, transform) {
|
|
156
|
+
const database = await this.database;
|
|
157
|
+
return new Promise((resolve, reject) => {
|
|
158
|
+
const transaction = database.transaction("profiles", "readwrite");
|
|
159
|
+
const store = transaction.objectStore("profiles");
|
|
160
|
+
const request = store.get([appId, profileId]);
|
|
161
|
+
let next, failure;
|
|
162
|
+
request.onsuccess = () => {
|
|
163
|
+
try {
|
|
164
|
+
next = transform(request.result ?? createReadIndex(appId, profileId));
|
|
165
|
+
if (next.appId !== appId || next.profileId !== profileId || next.schemaVersion !== 1)
|
|
166
|
+
throw new Error("READ_IDENTITY_MISMATCH");
|
|
167
|
+
store.put(next, [appId, profileId]);
|
|
168
|
+
}
|
|
169
|
+
catch (error) {
|
|
170
|
+
failure = error;
|
|
171
|
+
transaction.abort();
|
|
172
|
+
}
|
|
173
|
+
};
|
|
174
|
+
transaction.oncomplete = () => resolve(next);
|
|
175
|
+
transaction.onerror = () => reject(failure ?? transaction.error);
|
|
176
|
+
transaction.onabort = () => reject(failure ?? transaction.error ?? new Error("READ_STORAGE_ABORTED"));
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
async close() { (await this.database).close(); }
|
|
180
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pmate/chat-sdk",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.3",
|
|
4
4
|
"description": "Cross-platform PMate chat input components, voice input primitives, and ASR helpers",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./lib/index.js",
|
|
@@ -28,11 +28,20 @@
|
|
|
28
28
|
"publishConfig": {
|
|
29
29
|
"access": "public"
|
|
30
30
|
},
|
|
31
|
+
"scripts": {
|
|
32
|
+
"clean": "rimraf lib",
|
|
33
|
+
"build": "pnpm run clean && tsc -p tsconfig.build.json && cp src/voice/recorder-processor.js lib/voice/recorder-processor.js",
|
|
34
|
+
"prepack": "pnpm run build",
|
|
35
|
+
"npm:publish": "pnpm run build && npm publish --access public --registry https://registry.npmjs.org/",
|
|
36
|
+
"test": "vitest run",
|
|
37
|
+
"storybook": "storybook dev -p 6009",
|
|
38
|
+
"build-storybook": "storybook build --output-dir dist"
|
|
39
|
+
},
|
|
31
40
|
"dependencies": {
|
|
32
|
-
"@noble/hashes": "^2.4.0",
|
|
33
41
|
"@emoji-mart/data": "^1.2.1",
|
|
34
42
|
"@emoji-mart/react": "^1.1.1",
|
|
35
43
|
"@msgpack/msgpack": "^3.1.2",
|
|
44
|
+
"@noble/hashes": "^2.4.0",
|
|
36
45
|
"jotai": "^2.16.1"
|
|
37
46
|
},
|
|
38
47
|
"peerDependencies": {
|
|
@@ -42,23 +51,16 @@
|
|
|
42
51
|
"devDependencies": {
|
|
43
52
|
"@storybook/addon-docs": "^9.0.17",
|
|
44
53
|
"@storybook/react-vite": "9.0.17",
|
|
45
|
-
"@tailwindcss/vite": "
|
|
46
|
-
"@types/react": "
|
|
47
|
-
"@types/react-dom": "
|
|
48
|
-
"
|
|
49
|
-
"react
|
|
54
|
+
"@tailwindcss/vite": "catalog:",
|
|
55
|
+
"@types/react": "catalog:",
|
|
56
|
+
"@types/react-dom": "catalog:",
|
|
57
|
+
"fake-indexeddb": "^6.2.5",
|
|
58
|
+
"react": "catalog:",
|
|
59
|
+
"react-dom": "catalog:",
|
|
50
60
|
"rimraf": "^6.1.2",
|
|
51
61
|
"storybook": "^9.0.17",
|
|
52
|
-
"tailwindcss": "
|
|
62
|
+
"tailwindcss": "catalog:",
|
|
53
63
|
"typescript": "5.9.2",
|
|
54
64
|
"vitest": "^4.0.17"
|
|
55
|
-
},
|
|
56
|
-
"scripts": {
|
|
57
|
-
"clean": "rimraf lib",
|
|
58
|
-
"build": "pnpm run clean && tsc -p tsconfig.build.json && cp src/voice/recorder-processor.js lib/voice/recorder-processor.js",
|
|
59
|
-
"npm:publish": "pnpm run build && npm publish --access public --registry https://registry.npmjs.org/",
|
|
60
|
-
"test": "vitest run",
|
|
61
|
-
"storybook": "storybook dev -p 6009",
|
|
62
|
-
"build-storybook": "storybook build --output-dir dist"
|
|
63
65
|
}
|
|
64
|
-
}
|
|
66
|
+
}
|