@pmate/chat-sdk 0.12.0-next.65 → 0.12.0-next.67
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/lib/chat.d.ts +13 -2
- package/lib/chat.js +7 -1
- package/lib/index.d.ts +1 -1
- package/lib/messageView.d.ts +1 -0
- package/lib/messageView.js +1 -1
- package/lib/runtime/chatError.js +1 -1
- package/lib/runtime/chatSession.js +17 -6
- package/lib/runtime/conversationDirectory.d.ts +1 -0
- package/lib/runtime/conversationDirectory.js +7 -7
- package/lib/runtime/messageLaunch.d.ts +2 -0
- package/lib/runtime/messageLaunch.js +5 -1
- package/lib/runtime/responseCache.d.ts +2 -0
- package/lib/runtime/responseCache.js +13 -11
- package/package.json +1 -1
package/lib/chat.d.ts
CHANGED
|
@@ -48,6 +48,17 @@ export type MessageQuery = {
|
|
|
48
48
|
all?: boolean;
|
|
49
49
|
refresh?: boolean;
|
|
50
50
|
};
|
|
51
|
+
export type ReadersQuery = {
|
|
52
|
+
afterProfileId?: string;
|
|
53
|
+
revision?: number;
|
|
54
|
+
};
|
|
55
|
+
export type GroupChanges = {
|
|
56
|
+
expectedRevision?: string;
|
|
57
|
+
addMembers?: readonly string[];
|
|
58
|
+
removeMembers?: readonly string[];
|
|
59
|
+
title?: string;
|
|
60
|
+
avatar?: string;
|
|
61
|
+
};
|
|
51
62
|
export type SendOptions = {
|
|
52
63
|
waitFor?: "accepted" | "delivered";
|
|
53
64
|
};
|
|
@@ -92,7 +103,7 @@ export declare class Chat {
|
|
|
92
103
|
private notify;
|
|
93
104
|
observeMessages(threadId: string, ids: string[], event: "visible" | "played"): Promise<void>;
|
|
94
105
|
markRead(threadId: string): Promise<void>;
|
|
95
|
-
getReaders(threadId: string, messageId: string, page?:
|
|
106
|
+
getReaders(threadId: string, messageId: string, page?: ReadersQuery, signal?: AbortSignal): Promise<import("./runtime/messageReaders.js").MessageReadersPage>;
|
|
96
107
|
watchReaders(threadId: string | undefined, ids: string[], receiver?: string): Promise<void>;
|
|
97
108
|
handleReach(event: Parameters<ChatSession<CachedChatMessage>["handleReach"]>[0]): Promise<"handled" | undefined>;
|
|
98
109
|
getVoiceAttachment(threadId: string, messageId: string): Promise<import("./runtime/durableSender.js").VoiceAttachmentLease>;
|
|
@@ -113,7 +124,7 @@ export declare class Chat {
|
|
|
113
124
|
ownerId: string;
|
|
114
125
|
memberIds: readonly string[];
|
|
115
126
|
}>;
|
|
116
|
-
updateGroup(threadId: string, changes:
|
|
127
|
+
updateGroup(threadId: string, changes: GroupChanges): Promise<Readonly<Omit<RuntimeGroup, "versionNumber">>>;
|
|
117
128
|
revokeAccess(): Promise<void>;
|
|
118
129
|
close(): Promise<void>;
|
|
119
130
|
}
|
package/lib/chat.js
CHANGED
|
@@ -191,7 +191,13 @@ export class Chat {
|
|
|
191
191
|
notify(threadId) { this.listeners.get(threadId)?.forEach(listener => listener()); this.listeners.get("")?.forEach(listener => listener()); }
|
|
192
192
|
observeMessages(threadId, ids, event) { return this.threadOperation(threadId, async () => { await this.advanced.observeMessages(threadId, ids, event); }); }
|
|
193
193
|
markRead(threadId) { return this.threadOperation(threadId, async () => { await this.advanced.markRead(threadId); }); }
|
|
194
|
-
getReaders(threadId, messageId, page = {}, signal) {
|
|
194
|
+
getReaders(threadId, messageId, page = {}, signal) {
|
|
195
|
+
return this.threadOperation(threadId, async () => {
|
|
196
|
+
if (Object.keys(page).some(key => !["afterProfileId", "revision"].includes(key)))
|
|
197
|
+
throw new ChatError("invalid-input", "Unsupported reader query");
|
|
198
|
+
return this.advanced.getReaders(threadId, messageId, page, signal);
|
|
199
|
+
});
|
|
200
|
+
}
|
|
195
201
|
watchReaders(threadId, ids, receiver) { return this.threadOperation(threadId, () => this.advanced.observeMessages(threadId, ids, "receipts", receiver)); }
|
|
196
202
|
handleReach(event) { return this.operation(() => this.advanced.handleReach(event)); }
|
|
197
203
|
getVoiceAttachment(threadId, messageId) { return this.threadOperation(threadId, () => this.advanced.getVoiceAttachment(threadId, messageId)); }
|
package/lib/index.d.ts
CHANGED
|
@@ -41,5 +41,5 @@ export * from "./runtime/socialReadClient";
|
|
|
41
41
|
export * from "./ux/imagePaste";
|
|
42
42
|
export * from "./hooks/useImageAttachments";
|
|
43
43
|
export * from "./components/ImageAttachmentTray";
|
|
44
|
-
export { createChat, Chat, ChatError, type ChatThreadSnapshot, type ChatMessage as MessageView, type ChatErrorCode, type ConversationTarget, type OutgoingContent, type CreateChatOptions, type ChatSnapshot, type SentMessage, type MessageQuery } from "./chat.js";
|
|
44
|
+
export { createChat, Chat, ChatError, type ChatThreadSnapshot, type ChatMessage as MessageView, type ChatErrorCode, type ConversationTarget, type OutgoingContent, type CreateChatOptions, type ChatSnapshot, type SentMessage, type MessageQuery, type ReadersQuery, type GroupChanges } from "./chat.js";
|
|
45
45
|
export { useChatSnapshot } from "./hooks/useChatSnapshot.js";
|
package/lib/messageView.d.ts
CHANGED
package/lib/messageView.js
CHANGED
|
@@ -38,7 +38,7 @@ function content(message) {
|
|
|
38
38
|
const imageUrl = media(payload.url) ?? (hint.kind === "image" ? media(hint.imageUrl) : undefined);
|
|
39
39
|
const messageQuote = quote(payload.quote) ?? quote(hint.quote);
|
|
40
40
|
const rawText = text(payload.text) || text(object(body.payload).message);
|
|
41
|
-
const result = { kind: "text", text: validLegacy ? text(hint.content) : rawText, senderName: text(hint.senderName) || undefined, senderAvatar: media(hint.senderAvatar),
|
|
41
|
+
const result = { kind: "text", text: validLegacy ? text(hint.content) : rawText, senderName: text(hint.senderName) || undefined, senderAvatar: media(hint.senderAvatar), timeLabel: text(hint.createdAt) || undefined,
|
|
42
42
|
recipientCount: message.threadKind === "dm" ? 1 : validLegacy ? typeof hint.recipientCount === "number" ? hint.recipientCount : undefined : Array.isArray(message.recipients) ? new Set(message.recipients.filter(id => id !== message.from)).size : undefined };
|
|
43
43
|
if (validLegacy && ["emoji", "system", "attachment", "quote"].includes(String(hint.kind)))
|
|
44
44
|
result.kind = hint.kind;
|
package/lib/runtime/chatError.js
CHANGED
|
@@ -20,7 +20,7 @@ export function asChatError(error) {
|
|
|
20
20
|
: /CLOSED/.test(message) ? "closed"
|
|
21
21
|
: status === 409 || /REVISION_CONFLICT|PROJECTION_CHANGED/.test(message) ? "conflict"
|
|
22
22
|
: /BACKPRESSURE|TOO_LARGE/.test(message) ? "busy"
|
|
23
|
-
: status === 400 || /INVALID_|CONVERSATION_MISMATCH|NOT_OWNED/.test(message) ? "invalid-input"
|
|
23
|
+
: status === 400 || /INVALID_|CONVERSATION_MISMATCH|CONVERSATION_UNKNOWN|NOT_OWNED/.test(message) ? "invalid-input"
|
|
24
24
|
: /QuotaExceeded|DatabaseClosed|UnknownError|AbortError/.test(value?.name ?? "") || /STORAGE_|DATABASE_|CACHE_WRITE/.test(message) ? "storage-unavailable" : "unavailable";
|
|
25
25
|
return new ChatError(code, message, { cause: error, status });
|
|
26
26
|
}
|
|
@@ -444,21 +444,32 @@ export class ChatSession {
|
|
|
444
444
|
/** Validated target identity survives content redaction; no roster is exposed. */
|
|
445
445
|
getConversationTarget(threadId) {
|
|
446
446
|
const scope = this.options.messages.scope;
|
|
447
|
-
const
|
|
447
|
+
const snapshot = this.getSnapshot(threadId), group = snapshot.group;
|
|
448
448
|
if (group)
|
|
449
449
|
return { id: threadId, kind: "group", targetId: group.groupId, teamId: scope.teamId };
|
|
450
|
-
|
|
451
|
-
const row = value;
|
|
450
|
+
const infer = (row) => {
|
|
452
451
|
const kind = row.threadKind ?? row.body?.threadKind;
|
|
453
452
|
if ((kind !== "group" && kind !== "dm") || !row.to || !row.from)
|
|
454
|
-
|
|
453
|
+
return undefined;
|
|
455
454
|
const thread = { id: threadId, kind, targetId: kind === "group" || row.from === scope.profileId ? row.to : row.from, teamId: scope.teamId };
|
|
456
|
-
// A stored row is only a hint; validate the canonical scoped identifier.
|
|
457
455
|
try {
|
|
458
456
|
validateConversation(scope, thread);
|
|
459
457
|
return thread;
|
|
460
458
|
}
|
|
461
|
-
catch {
|
|
459
|
+
catch {
|
|
460
|
+
return undefined;
|
|
461
|
+
}
|
|
462
|
+
};
|
|
463
|
+
for (const value of snapshot.messages) {
|
|
464
|
+
const thread = infer(value);
|
|
465
|
+
if (thread)
|
|
466
|
+
return thread;
|
|
467
|
+
}
|
|
468
|
+
// Older integrations may have a durable send but no registered target or indexed history yet.
|
|
469
|
+
for (const value of snapshot.outgoing) {
|
|
470
|
+
const thread = infer(value.message);
|
|
471
|
+
if (thread)
|
|
472
|
+
return thread;
|
|
462
473
|
}
|
|
463
474
|
return undefined;
|
|
464
475
|
}
|
|
@@ -30,6 +30,7 @@ export declare class ConversationDirectory {
|
|
|
30
30
|
put(thread: ChatConversation): Promise<ChatConversation>;
|
|
31
31
|
private persist;
|
|
32
32
|
get(id: string): Promise<ChatConversation | undefined>;
|
|
33
|
+
private lookup;
|
|
33
34
|
private flush;
|
|
34
35
|
retire(version: number): Promise<void>;
|
|
35
36
|
close(): Promise<void>;
|
|
@@ -19,7 +19,7 @@ export class ConversationDirectory {
|
|
|
19
19
|
constructor(scope, access) {
|
|
20
20
|
this.scope = scope;
|
|
21
21
|
this.access = access;
|
|
22
|
-
this.cache = new IndexedDbResponseCache(`conversations:${messageScopeKey(scope)}`, { access: () => this.authorize(), entries: 256, recordBytes: 4096 });
|
|
22
|
+
this.cache = new IndexedDbResponseCache(`conversations:${messageScopeKey(scope)}`, { access: () => this.authorize(), entries: 256, recordBytes: 4096, retainAll: true });
|
|
23
23
|
}
|
|
24
24
|
authorize() { return this.authority ??= this.access().finally(() => { this.authority = undefined; }); }
|
|
25
25
|
assertOpen() { if (this.retired)
|
|
@@ -56,18 +56,18 @@ export class ConversationDirectory {
|
|
|
56
56
|
this.assertOpen();
|
|
57
57
|
validateConversation(this.scope, thread);
|
|
58
58
|
const value = { id: thread.id, kind: thread.kind, targetId: thread.targetId, teamId: this.scope.teamId };
|
|
59
|
-
const
|
|
59
|
+
const row = await this.lookup(thread.id), previous = row.value;
|
|
60
60
|
if (previous) {
|
|
61
61
|
if (previous.kind !== value.kind || previous.targetId !== value.targetId)
|
|
62
62
|
throw new Error("MESSAGE_CONVERSATION_MISMATCH");
|
|
63
63
|
return previous;
|
|
64
64
|
}
|
|
65
|
-
const row = await this.cache.read(thread.id);
|
|
66
65
|
await this.cache.write(thread.id, value, row.epoch);
|
|
67
66
|
this.assertOpen();
|
|
68
67
|
return this.remember(value);
|
|
69
68
|
}
|
|
70
|
-
async get(id) {
|
|
69
|
+
async get(id) { return (await this.lookup(id)).value; }
|
|
70
|
+
async lookup(id) {
|
|
71
71
|
this.assertOpen();
|
|
72
72
|
const authority = await this.authorize();
|
|
73
73
|
this.assertOpen();
|
|
@@ -78,7 +78,7 @@ export class ConversationDirectory {
|
|
|
78
78
|
this.version = authority.version;
|
|
79
79
|
const known = this.memory.get(id);
|
|
80
80
|
if (known)
|
|
81
|
-
return this.remember(known);
|
|
81
|
+
return { value: this.remember(known), epoch: 0 };
|
|
82
82
|
const existing = this.pending.get(id);
|
|
83
83
|
if (existing)
|
|
84
84
|
return existing;
|
|
@@ -98,13 +98,13 @@ export class ConversationDirectory {
|
|
|
98
98
|
const rows = await this.cache.readMany(batch.map(item => item.id));
|
|
99
99
|
this.assertOpen();
|
|
100
100
|
for (const item of batch) {
|
|
101
|
-
const
|
|
101
|
+
const row = rows.get(item.id), value = row.cached?.value;
|
|
102
102
|
if (value) {
|
|
103
103
|
if (value.id !== item.id)
|
|
104
104
|
throw new Error("MESSAGE_CONVERSATION_MISMATCH");
|
|
105
105
|
validateConversation(this.scope, value);
|
|
106
106
|
}
|
|
107
|
-
item.resolve(value ? this.remember(value) : undefined);
|
|
107
|
+
item.resolve({ value: value ? this.remember(value) : undefined, epoch: row.epoch });
|
|
108
108
|
}
|
|
109
109
|
}
|
|
110
110
|
catch (error) {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type ChatMessage } from "../messageView.js";
|
|
1
2
|
import { type MessageScope } from "./messageStore.js";
|
|
2
3
|
import { type CachedChatMessage } from "./cachedMessage.js";
|
|
3
4
|
import { type TimelineItem } from "./messageTimeline.js";
|
|
@@ -12,6 +13,7 @@ export type MessageLaunchView = {
|
|
|
12
13
|
threadId: string;
|
|
13
14
|
metadata: MessageLaunchMetadata;
|
|
14
15
|
images: Record<string, string>;
|
|
16
|
+
messages: readonly ChatMessage[]; /** @deprecated Advanced compatibility projection. */
|
|
15
17
|
timeline: readonly TimelineItem<CachedChatMessage>[];
|
|
16
18
|
};
|
|
17
19
|
type Options = {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createMessagePresenter } from "../messageView.js";
|
|
1
2
|
import { Dexie } from "dexie";
|
|
2
3
|
import { IndexedDbMessageStore, messageScopeKey, mergeStoredMessage } from "./messageStore.js";
|
|
3
4
|
import { DurableChatSender } from "./durableSender.js";
|
|
@@ -47,6 +48,7 @@ export async function saveMessageLaunch(input, options = {}) {
|
|
|
47
48
|
export function watchMessageLaunch(input, onChange, onError, options = {}) {
|
|
48
49
|
const { scope, openId, threadId } = input, key = messageScopeKey(scope), store = messages(scope, options), { db, rows } = tickets(options);
|
|
49
50
|
let closed = false, generation = 0, ticket, timer;
|
|
51
|
+
const present = createMessagePresenter();
|
|
50
52
|
const channels = [];
|
|
51
53
|
const stop = () => { if (closed)
|
|
52
54
|
return; closed = true; generation++; clearTimeout(timer); tasks.close(); channels.forEach(channel => channel.close()); store.close(); db.close(); };
|
|
@@ -89,7 +91,9 @@ export function watchMessageLaunch(input, onChange, onError, options = {}) {
|
|
|
89
91
|
return;
|
|
90
92
|
}
|
|
91
93
|
const timeline = projectMessageTimeline({ appId: scope.appId, profileId: scope.profileId, threadId, messages: page.messages, outgoing, decode: message => decodeCachedChatMessage(message, scope), reading: {} }).slice(-30);
|
|
92
|
-
|
|
94
|
+
const normalized = timeline.map(present), visibleImages = new Set(normalized.flatMap(message => message.image ? [message.image.url] : []));
|
|
95
|
+
const images = Object.fromEntries(Object.entries(ticket.images).filter(([url]) => visibleImages.has(url)));
|
|
96
|
+
onChange(timeline.length ? { openId, threadId, metadata: ticket.metadata, images, messages: normalized, timeline } : undefined);
|
|
93
97
|
};
|
|
94
98
|
const tasks = new WindowedTasks(16, fail);
|
|
95
99
|
if (typeof BroadcastChannel !== "undefined") {
|
|
@@ -8,6 +8,8 @@ export type ResponseCacheOptions<T> = {
|
|
|
8
8
|
indexedDB?: IDBFactory;
|
|
9
9
|
name?: string;
|
|
10
10
|
access?: ResponseCacheAccess;
|
|
11
|
+
/** Immutable identity metadata must survive ordinary response-window eviction. */
|
|
12
|
+
retainAll?: boolean;
|
|
11
13
|
recordBytes?: number;
|
|
12
14
|
entries?: number;
|
|
13
15
|
batchRecords?: number;
|
|
@@ -59,7 +59,7 @@ export class IndexedDbResponseCache {
|
|
|
59
59
|
throw new Error("RESPONSE_CACHE_SCOPE_CHANGED");
|
|
60
60
|
if (access.version > version) {
|
|
61
61
|
await this.epochs.put({ scope: this.scope, epoch: (current?.epoch ?? 0) + 1, accessVersion: access.version });
|
|
62
|
-
await this.rows.where("scope").
|
|
62
|
+
await this.rows.where("[scope+key]").between([this.scope, Dexie.minKey], [this.scope, Dexie.maxKey], true, true).delete();
|
|
63
63
|
}
|
|
64
64
|
});
|
|
65
65
|
if (!access.allowed)
|
|
@@ -114,7 +114,7 @@ export class IndexedDbResponseCache {
|
|
|
114
114
|
if ((current?.accessVersion ?? 0) >= version)
|
|
115
115
|
return;
|
|
116
116
|
await this.epochs.put({ scope: this.scope, epoch: (current?.epoch ?? 0) + 1, accessVersion: version });
|
|
117
|
-
await this.rows.where("scope").
|
|
117
|
+
await this.rows.where("[scope+key]").between([this.scope, Dexie.minKey], [this.scope, Dexie.maxKey], true, true).delete();
|
|
118
118
|
});
|
|
119
119
|
this.invalidations.add(task);
|
|
120
120
|
return task.finally(() => this.invalidations.delete(task));
|
|
@@ -127,7 +127,7 @@ export class IndexedDbResponseCache {
|
|
|
127
127
|
const current = await this.epochs.get(this.scope);
|
|
128
128
|
const epoch = (current?.epoch ?? 0) + 1;
|
|
129
129
|
await this.epochs.put({ scope: this.scope, epoch, accessVersion: current?.accessVersion });
|
|
130
|
-
await this.rows.where("scope").
|
|
130
|
+
await this.rows.where("[scope+key]").between([this.scope, Dexie.minKey], [this.scope, Dexie.maxKey], true, true).delete();
|
|
131
131
|
});
|
|
132
132
|
})();
|
|
133
133
|
this.invalidations.add(task);
|
|
@@ -178,14 +178,16 @@ export class IndexedDbResponseCache {
|
|
|
178
178
|
}
|
|
179
179
|
if (unique.size) {
|
|
180
180
|
await this.rows.bulkPut([...unique.values()]);
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
181
|
+
if (!this.options.retainAll) {
|
|
182
|
+
const stored = await this.rows.where("scope").equals(this.scope).toArray();
|
|
183
|
+
stored.sort((a, b) => b.time - a.time || a.key.localeCompare(b.key));
|
|
184
|
+
let bytes = 0;
|
|
185
|
+
const evicted = [];
|
|
186
|
+
stored.forEach((row, index) => { bytes += row.bytes; if (index >= (this.options.entries ?? 256) || bytes > 1048576)
|
|
187
|
+
evicted.push([this.scope, row.key]); });
|
|
188
|
+
if (evicted.length)
|
|
189
|
+
await this.rows.bulkDelete(evicted);
|
|
190
|
+
}
|
|
189
191
|
}
|
|
190
192
|
return { accepted, changed };
|
|
191
193
|
});
|
package/package.json
CHANGED