@pmate/chat-sdk 0.12.0-next.40 → 0.12.0-next.41
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/runtime/cachedMessage.d.ts +12 -0
- package/lib/runtime/cachedMessage.js +18 -0
- package/lib/runtime/createChat.d.ts +2 -6
- package/lib/runtime/createChat.js +3 -19
- package/lib/runtime/durableSender.d.ts +2 -0
- package/lib/runtime/durableSender.js +19 -0
- package/lib/runtime/index.d.ts +1 -0
- package/lib/runtime/index.js +1 -0
- package/lib/runtime/messageLaunch.d.ts +42 -0
- package/lib/runtime/messageLaunch.js +127 -0
- package/lib/runtime/messageQuotes.d.ts +3 -0
- package/lib/runtime/messageQuotes.js +17 -0
- package/lib/runtime/messageStore.d.ts +6 -0
- package/lib/runtime/messageStore.js +23 -2
- package/package.json +6 -1
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { ChatReaderSocialMessage } from "./readerClient.js";
|
|
2
|
+
import type { ChatRuntimeMessage } from "./protocol.js";
|
|
3
|
+
export type CachedChatMessage = ChatReaderSocialMessage & {
|
|
4
|
+
id: string;
|
|
5
|
+
timestamp: number;
|
|
6
|
+
readPolicy?: "visible" | "explicit";
|
|
7
|
+
};
|
|
8
|
+
export declare function decodeCachedChatMessage(message: ChatRuntimeMessage, scope: {
|
|
9
|
+
appId: string;
|
|
10
|
+
profileId: string;
|
|
11
|
+
teamId: string;
|
|
12
|
+
}): CachedChatMessage | undefined;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { chatTeamId } from "./teamScope.js";
|
|
2
|
+
import { dmThreadId, groupThreadId } from "./thread.js";
|
|
3
|
+
export function decodeCachedChatMessage(message, scope) {
|
|
4
|
+
if (message.opcode !== 1 || !message.body || typeof message.body !== "object")
|
|
5
|
+
return;
|
|
6
|
+
const body = message.body;
|
|
7
|
+
const invitation = body.kind === "contact" && ["friendRequest", "requestFriend"].includes(String(body.action));
|
|
8
|
+
if ((!invitation && body.kind !== "message") || (invitation ? body.payload?.appId : body.content?.appId) !== scope.appId || chatTeamId(body) !== scope.teamId)
|
|
9
|
+
return;
|
|
10
|
+
const threadKind = invitation ? "dm" : body.threadKind;
|
|
11
|
+
if (threadKind !== "dm" && threadKind !== "group")
|
|
12
|
+
return;
|
|
13
|
+
if (threadKind === "dm" && message.from !== scope.profileId && message.to !== scope.profileId)
|
|
14
|
+
return;
|
|
15
|
+
const threadId = threadKind === "dm" ? dmThreadId(scope.appId, message.from, message.to, scope.teamId) : groupThreadId(scope.appId, message.to, scope.teamId);
|
|
16
|
+
return { appId: scope.appId, teamId: scope.teamId, threadId, threadKind, messageId: message.hash, id: message.hash,
|
|
17
|
+
timestamp: message.t, serverCreatedAt: message.t, clientCreatedAt: message.t, from: message.from, to: message.to, body: message.body, recipients: message.recipients };
|
|
18
|
+
}
|
|
@@ -1,11 +1,7 @@
|
|
|
1
1
|
import { ChatSession } from "./chatSession.js";
|
|
2
|
-
import type { ChatReaderSocialMessage } from "./readerClient.js";
|
|
3
2
|
import type { ChatClient, ChatClientOptions } from "./chatClient.js";
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
timestamp: number;
|
|
7
|
-
readPolicy?: "visible" | "explicit";
|
|
8
|
-
};
|
|
3
|
+
import { type CachedChatMessage } from "./cachedMessage.js";
|
|
4
|
+
export type { CachedChatMessage } from "./cachedMessage.js";
|
|
9
5
|
export type CreateChatOptions = {
|
|
10
6
|
appId: string;
|
|
11
7
|
accountId: string;
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { isReadingControl, messageRequiresPlayback, messageMentionsProfile } from "./readingContent.js";
|
|
2
2
|
import { ChatSession } from "./chatSession.js";
|
|
3
3
|
import { SocialReadClient } from "./socialReadClient.js";
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
4
|
+
import { isValidChatTeamId } from "./thread.js";
|
|
5
|
+
import { decodeCachedChatMessage } from "./cachedMessage.js";
|
|
6
6
|
/** Standard browser entry: storage, paging, realtime and reading share one identity. */
|
|
7
7
|
export function createChat(options) {
|
|
8
8
|
const { appId, accountId, profileId, teamId, token, onError } = options;
|
|
@@ -31,22 +31,6 @@ export function createChat(options) {
|
|
|
31
31
|
return { id: message.id, threadId: message.threadId, from: message.from, mine: message.from === profileId, recalled: message.recalled, unread: message.from !== profileId,
|
|
32
32
|
readPolicy: message.readPolicy === "explicit" || message.legacyDisplay?.kind === "voice" || messageRequiresPlayback(body) ? "explicit" : "visible", attentionEligible: messageMentionsProfile(body, profileId) };
|
|
33
33
|
},
|
|
34
|
-
decode: message =>
|
|
34
|
+
decode: message => decodeCachedChatMessage(message, { appId, profileId, teamId }), onError,
|
|
35
35
|
});
|
|
36
36
|
}
|
|
37
|
-
function decodeIncoming(message, scope) {
|
|
38
|
-
if (message.opcode !== 1 || !message.body || typeof message.body !== "object")
|
|
39
|
-
return;
|
|
40
|
-
const body = message.body;
|
|
41
|
-
const invitation = body.kind === "contact" && ["friendRequest", "requestFriend"].includes(String(body.action));
|
|
42
|
-
if ((!invitation && body.kind !== "message") || (invitation ? body.payload?.appId : body.content?.appId) !== scope.appId || chatTeamId(body) !== scope.teamId)
|
|
43
|
-
return;
|
|
44
|
-
const threadKind = invitation ? "dm" : body.threadKind;
|
|
45
|
-
if (threadKind !== "dm" && threadKind !== "group")
|
|
46
|
-
return;
|
|
47
|
-
if (threadKind === "dm" && message.from !== scope.profileId && message.to !== scope.profileId)
|
|
48
|
-
return;
|
|
49
|
-
const threadId = threadKind === "dm" ? dmThreadId(scope.appId, message.from, message.to, scope.teamId) : groupThreadId(scope.appId, message.to, scope.teamId);
|
|
50
|
-
return { appId: scope.appId, teamId: scope.teamId, threadId, threadKind, messageId: message.hash, id: message.hash,
|
|
51
|
-
timestamp: message.t, serverCreatedAt: message.t, clientCreatedAt: message.t, from: message.from, to: message.to, body: message.body, recipients: message.recipients };
|
|
52
|
-
}
|
|
@@ -44,6 +44,8 @@ export declare class DurableChatSender {
|
|
|
44
44
|
private offConnection;
|
|
45
45
|
private ready;
|
|
46
46
|
constructor(client: ChatClient, scope: MessageScope, onError: (error: unknown) => void, options?: DurableSenderOptions);
|
|
47
|
+
/** Read only the selected durable rows, without restoring or delivering an outbox. */
|
|
48
|
+
static readSelected(scope: MessageScope, threadId: string, ids: readonly string[], version: number, options?: Pick<DurableSenderOptions, "indexedDB" | "name">): Promise<OutgoingMessage[]>;
|
|
47
49
|
private readAccess;
|
|
48
50
|
private restore;
|
|
49
51
|
private checkAccess;
|
|
@@ -49,6 +49,25 @@ export class DurableChatSender {
|
|
|
49
49
|
void this.resume().catch(onError);
|
|
50
50
|
});
|
|
51
51
|
}
|
|
52
|
+
/** Read only the selected durable rows, without restoring or delivering an outbox. */
|
|
53
|
+
static async readSelected(scope, threadId, ids, version, options = {}) {
|
|
54
|
+
if (ids.length > 100)
|
|
55
|
+
throw new Error("INVALID_OUTBOX_SELECTION");
|
|
56
|
+
const key = messageScopeKey(scope), db = new Dexie(options.name ?? "pmate-chat-outbox", { indexedDB: options.indexedDB ?? globalThis.indexedDB, IDBKeyRange: globalThis.IDBKeyRange });
|
|
57
|
+
db.version(1).stores({ outbox: "[scope+hash],scope,[scope+threadId]" });
|
|
58
|
+
db.version(2).stores({ scopeEpochs: "scope" });
|
|
59
|
+
try {
|
|
60
|
+
return await db.transaction("r", db.table("outbox"), db.table("scopeEpochs"), async () => {
|
|
61
|
+
if (((await db.table("scopeEpochs").get(key))?.version ?? 0) !== version)
|
|
62
|
+
return [];
|
|
63
|
+
const rows = await db.table("outbox").bulkGet(ids.map(id => [key, id]));
|
|
64
|
+
return rows.flatMap(row => row && row.scope === key && row.threadId === threadId && !row.confirmed && (row.accessVersion ?? 0) === version ? [row] : []);
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
finally {
|
|
68
|
+
db.close();
|
|
69
|
+
}
|
|
70
|
+
}
|
|
52
71
|
async readAccess() {
|
|
53
72
|
const access = await this.options.access?.();
|
|
54
73
|
if (access && (!Number.isSafeInteger(access.version) || access.version < 0 || typeof access.allowed !== "boolean"))
|
package/lib/runtime/index.d.ts
CHANGED
|
@@ -32,3 +32,4 @@ export * from "./createChat.js";
|
|
|
32
32
|
export * from "./conversationRead.js";
|
|
33
33
|
export { projectConversationReading, type ReadingCandidate, type MessageReadingState, type ConversationReadingView } from "./conversationReading.js";
|
|
34
34
|
export { projectMessageTimeline, type TimelineItem, type TimelineReaction, type TimelineReactionEvent } from "./messageTimeline.js";
|
|
35
|
+
export * from "./messageLaunch.js";
|
package/lib/runtime/index.js
CHANGED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { type MessageScope } from "./messageStore.js";
|
|
2
|
+
import { type CachedChatMessage } from "./cachedMessage.js";
|
|
3
|
+
import { type TimelineItem } from "./messageTimeline.js";
|
|
4
|
+
export type MessageLaunchMetadata = {
|
|
5
|
+
title: string;
|
|
6
|
+
subtitle?: string;
|
|
7
|
+
kind: "dm" | "group";
|
|
8
|
+
peerId: string;
|
|
9
|
+
};
|
|
10
|
+
export type MessageLaunchView = {
|
|
11
|
+
openId: string;
|
|
12
|
+
threadId: string;
|
|
13
|
+
metadata: MessageLaunchMetadata;
|
|
14
|
+
images: Record<string, string>;
|
|
15
|
+
timeline: readonly TimelineItem<CachedChatMessage>[];
|
|
16
|
+
};
|
|
17
|
+
type Options = {
|
|
18
|
+
indexedDB?: IDBFactory;
|
|
19
|
+
name?: string;
|
|
20
|
+
messagesName?: string;
|
|
21
|
+
outboxName?: string;
|
|
22
|
+
};
|
|
23
|
+
/** A short-lived ticket contains references and display metadata, never message bodies. */
|
|
24
|
+
export declare function saveMessageLaunch(input: {
|
|
25
|
+
scope: MessageScope;
|
|
26
|
+
openId: string;
|
|
27
|
+
threadId: string;
|
|
28
|
+
ids: readonly string[];
|
|
29
|
+
metadata: MessageLaunchMetadata;
|
|
30
|
+
images?: Readonly<Record<string, string>>;
|
|
31
|
+
}, options?: Options): Promise<boolean>;
|
|
32
|
+
/** Observe one local handoff through canonical message/control and outbox stores.
|
|
33
|
+
* This host never creates a transport, marks reading, or writes a second body cache. */
|
|
34
|
+
export declare function watchMessageLaunch(input: {
|
|
35
|
+
scope: MessageScope;
|
|
36
|
+
openId: string;
|
|
37
|
+
threadId: string;
|
|
38
|
+
}, onChange: (view: MessageLaunchView | undefined) => void, onError: (error: unknown) => void, options?: Options): {
|
|
39
|
+
ready: Promise<void>;
|
|
40
|
+
close: () => void;
|
|
41
|
+
};
|
|
42
|
+
export {};
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { Dexie } from "dexie";
|
|
2
|
+
import { IndexedDbMessageStore, messageScopeKey, mergeStoredMessage } from "./messageStore.js";
|
|
3
|
+
import { DurableChatSender } from "./durableSender.js";
|
|
4
|
+
import { decodeCachedChatMessage } from "./cachedMessage.js";
|
|
5
|
+
import { projectMessageTimeline } from "./messageTimeline.js";
|
|
6
|
+
import { WindowedTasks } from "./coalescer.js";
|
|
7
|
+
function tickets(options) {
|
|
8
|
+
const db = new Dexie(options.name ?? "pmate-chat-launches", { indexedDB: options.indexedDB ?? globalThis.indexedDB, IDBKeyRange: globalThis.IDBKeyRange });
|
|
9
|
+
db.version(1).stores({ tickets: "[scope+openId],scope,expiresAt" });
|
|
10
|
+
return { db, rows: db.table("tickets") };
|
|
11
|
+
}
|
|
12
|
+
function messages(scope, options) { return new IndexedDbMessageStore(scope, mergeStoredMessage, { indexedDB: options.indexedDB, name: options.messagesName }); }
|
|
13
|
+
function valid(value) { return typeof value === "string" && value.length > 0 && value.length <= 256 && !/[\u0000-\u001f]/.test(value); }
|
|
14
|
+
/** A short-lived ticket contains references and display metadata, never message bodies. */
|
|
15
|
+
export async function saveMessageLaunch(input, options = {}) {
|
|
16
|
+
const { scope, openId, threadId } = input;
|
|
17
|
+
if (!Object.values(scope).every(valid) || !valid(openId) || !valid(threadId) || input.ids.length > 30 || input.ids.some(id => !valid(id)))
|
|
18
|
+
throw new Error("INVALID_MESSAGE_LAUNCH");
|
|
19
|
+
if (!["dm", "group"].includes(input.metadata.kind) || !valid(input.metadata.peerId) || typeof input.metadata.title !== "string")
|
|
20
|
+
throw new Error("INVALID_MESSAGE_LAUNCH");
|
|
21
|
+
const store = messages(scope, options), { db, rows } = tickets(options);
|
|
22
|
+
try {
|
|
23
|
+
const page = await store.readSelected(threadId, []), coverage = page.coverage;
|
|
24
|
+
if (!coverage?.complete || coverage.revoked || !input.ids.length)
|
|
25
|
+
return false;
|
|
26
|
+
const images = {};
|
|
27
|
+
for (const [url, data] of Object.entries(input.images ?? {}).slice(-4))
|
|
28
|
+
if (url.length <= 2048 && data.length <= 65536 && /^data:image\/(webp|png);base64,[A-Za-z0-9+/=]+$/.test(data))
|
|
29
|
+
images[url] = data;
|
|
30
|
+
const ticket = { scope: messageScopeKey(scope), openId, threadId, expiresAt: Date.now() + 30_000, scopeVersion: coverage.scopeVersion ?? 0, resetVersion: coverage.resetVersion ?? 0, ids: [...new Set(input.ids)], metadata: { title: input.metadata.title.slice(0, 200), subtitle: input.metadata.subtitle?.slice(0, 200), kind: input.metadata.kind, peerId: input.metadata.peerId }, images };
|
|
31
|
+
await db.transaction("rw", rows, async () => {
|
|
32
|
+
await rows.where("expiresAt").below(Date.now()).limit(8).delete();
|
|
33
|
+
await rows.put(ticket);
|
|
34
|
+
const excess = await rows.count() - 8;
|
|
35
|
+
if (excess > 0)
|
|
36
|
+
await rows.bulkDelete(await rows.orderBy("expiresAt").limit(excess).primaryKeys());
|
|
37
|
+
});
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
finally {
|
|
41
|
+
store.close();
|
|
42
|
+
db.close();
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
/** Observe one local handoff through canonical message/control and outbox stores.
|
|
46
|
+
* This host never creates a transport, marks reading, or writes a second body cache. */
|
|
47
|
+
export function watchMessageLaunch(input, onChange, onError, options = {}) {
|
|
48
|
+
const { scope, openId, threadId } = input, key = messageScopeKey(scope), store = messages(scope, options), { db, rows } = tickets(options);
|
|
49
|
+
let closed = false, generation = 0, ticket, timer;
|
|
50
|
+
const channels = [];
|
|
51
|
+
const stop = () => { if (closed)
|
|
52
|
+
return; closed = true; generation++; clearTimeout(timer); tasks.close(); channels.forEach(channel => channel.close()); store.close(); db.close(); };
|
|
53
|
+
const hide = () => { try {
|
|
54
|
+
if (!closed)
|
|
55
|
+
onChange(undefined);
|
|
56
|
+
}
|
|
57
|
+
finally {
|
|
58
|
+
stop();
|
|
59
|
+
} };
|
|
60
|
+
const fail = (error) => { if (!closed) {
|
|
61
|
+
try {
|
|
62
|
+
onError(error);
|
|
63
|
+
}
|
|
64
|
+
finally {
|
|
65
|
+
hide();
|
|
66
|
+
}
|
|
67
|
+
} };
|
|
68
|
+
const refresh = async () => {
|
|
69
|
+
const run = ++generation;
|
|
70
|
+
if (!ticket || closed)
|
|
71
|
+
return;
|
|
72
|
+
if (ticket.expiresAt <= Date.now()) {
|
|
73
|
+
hide();
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
const page = await store.readSelected(threadId, ticket.ids), coverage = page.coverage;
|
|
77
|
+
if (closed || run !== generation)
|
|
78
|
+
return;
|
|
79
|
+
if (!coverage?.complete || coverage.revoked || (coverage.scopeVersion ?? 0) !== ticket.scopeVersion || (coverage.resetVersion ?? 0) !== ticket.resetVersion) {
|
|
80
|
+
hide();
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
const outgoing = await DurableChatSender.readSelected(scope, threadId, ticket.ids, ticket.scopeVersion, { indexedDB: options.indexedDB, name: options.outboxName });
|
|
84
|
+
const guard = (await store.readSelected(threadId, [])).coverage;
|
|
85
|
+
if (closed || run !== generation)
|
|
86
|
+
return;
|
|
87
|
+
if (!guard?.complete || guard.revoked || (guard.scopeVersion ?? 0) !== ticket.scopeVersion || (guard.resetVersion ?? 0) !== ticket.resetVersion) {
|
|
88
|
+
hide();
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
const timeline = projectMessageTimeline({ appId: scope.appId, profileId: scope.profileId, threadId, messages: page.messages, outgoing, decode: message => decodeCachedChatMessage(message, scope), reading: {} }).slice(-30);
|
|
92
|
+
onChange(timeline.length ? { openId, threadId, metadata: ticket.metadata, images: ticket.images, timeline } : undefined);
|
|
93
|
+
};
|
|
94
|
+
const tasks = new WindowedTasks(16, fail);
|
|
95
|
+
if (typeof BroadcastChannel !== "undefined") {
|
|
96
|
+
const cache = new BroadcastChannel(`pmate-chat:${key}${options.messagesName && options.messagesName !== "pmate-chat-messages" ? `:${options.messagesName}` : ""}`);
|
|
97
|
+
cache.onmessage = event => {
|
|
98
|
+
if (event.data?.type === "scope-revoked" || event.data?.type === "revoked" && event.data.thread === threadId) {
|
|
99
|
+
hide();
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
if (event.data === threadId || event.data?.thread === threadId)
|
|
103
|
+
tasks.schedule("view", refresh);
|
|
104
|
+
};
|
|
105
|
+
channels.push(cache);
|
|
106
|
+
const outbox = new BroadcastChannel(`pmate-chat-outbox:${key}:${options.outboxName ?? "pmate-chat-outbox"}`);
|
|
107
|
+
outbox.onmessage = () => tasks.schedule("view", refresh);
|
|
108
|
+
channels.push(outbox);
|
|
109
|
+
}
|
|
110
|
+
const ready = (async () => {
|
|
111
|
+
ticket = await db.transaction("rw", rows, async () => {
|
|
112
|
+
const value = await rows.get([key, openId]);
|
|
113
|
+
if (value)
|
|
114
|
+
await rows.delete([key, openId]);
|
|
115
|
+
return value;
|
|
116
|
+
});
|
|
117
|
+
if (closed)
|
|
118
|
+
return;
|
|
119
|
+
if (!ticket || ticket.scope !== key || ticket.threadId !== threadId || ticket.expiresAt <= Date.now() || ticket.ids.length > 30) {
|
|
120
|
+
hide();
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
timer = setTimeout(hide, Math.max(0, ticket.expiresAt - Date.now()));
|
|
124
|
+
await refresh();
|
|
125
|
+
})().catch(fail);
|
|
126
|
+
return { ready, close: stop };
|
|
127
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export function quotedMessageIds(message) {
|
|
2
|
+
const row = message;
|
|
3
|
+
return [row.body?.content?.quote?.messageId, row.legacyDisplay?.quote?.messageId].filter((id) => typeof id === "string" && id.length > 0 && id.length <= 256);
|
|
4
|
+
}
|
|
5
|
+
export function redactMessageQuotes(messages, targets) {
|
|
6
|
+
const recalled = new Set(targets.filter(message => message.recalled || message.legacyDisplay?.recalled).map(message => message.id));
|
|
7
|
+
if (!recalled.size)
|
|
8
|
+
return messages;
|
|
9
|
+
return messages.map(message => {
|
|
10
|
+
const row = message, wire = row.body?.content?.quote, legacy = row.legacyDisplay?.quote;
|
|
11
|
+
const hideWire = typeof wire?.messageId === "string" && recalled.has(wire.messageId);
|
|
12
|
+
const hideLegacy = typeof legacy?.messageId === "string" && recalled.has(legacy.messageId);
|
|
13
|
+
if (!hideWire && !hideLegacy)
|
|
14
|
+
return message;
|
|
15
|
+
return { ...message, ...(hideWire ? { body: { ...row.body, content: { ...row.body?.content, quote: { ...wire, content: "[消息已撤回]" } } } } : {}), ...(hideLegacy ? { legacyDisplay: { ...row.legacyDisplay, quote: { ...legacy, content: "[消息已撤回]" } } } : {}) };
|
|
16
|
+
});
|
|
17
|
+
}
|
|
@@ -55,6 +55,12 @@ export declare class IndexedDbMessageStore<T extends StoredMessage> {
|
|
|
55
55
|
hasEarlier: boolean;
|
|
56
56
|
coverage: MessageCoverage | undefined;
|
|
57
57
|
}>;
|
|
58
|
+
/** Bounded identifier reads for SDK-owned view handoffs; no transport fallback. */
|
|
59
|
+
readSelected(thread: string, ids: readonly string[]): Promise<{
|
|
60
|
+
messages: T[];
|
|
61
|
+
hasEarlier: boolean;
|
|
62
|
+
coverage: MessageCoverage | undefined;
|
|
63
|
+
}>;
|
|
58
64
|
/** Migration records never claim coverage or overwrite authoritative rows. */
|
|
59
65
|
importLegacy(thread: string, messages: T[]): Promise<number>;
|
|
60
66
|
/** Bounded fallback only; callers must report an incomplete window. */
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Dexie } from "dexie";
|
|
2
|
+
import { quotedMessageIds, redactMessageQuotes } from "./messageQuotes.js";
|
|
2
3
|
import { messageControls, controlKey, newerControl, applyMessageControls } from "./messageControls.js";
|
|
3
4
|
export const messageScopeKey = (scope) => JSON.stringify([scope.appId, scope.accountId, scope.profileId, scope.teamId]);
|
|
4
5
|
/** Normalized browser cache. Data and coverage advance together in each commit.
|
|
@@ -43,6 +44,20 @@ export class IndexedDbMessageStore {
|
|
|
43
44
|
});
|
|
44
45
|
return this.finishPage(thread, page);
|
|
45
46
|
}
|
|
47
|
+
/** Bounded identifier reads for SDK-owned view handoffs; no transport fallback. */
|
|
48
|
+
async readSelected(thread, ids) {
|
|
49
|
+
if (ids.length > 100 || ids.some(id => !id || id.length > 256))
|
|
50
|
+
throw new Error("INVALID_MESSAGE_SELECTION");
|
|
51
|
+
const page = await this.db.transaction("r", this.rows, this.coverage, this.accessRows, this.controls, async () => {
|
|
52
|
+
const access = await this.scopeAccess();
|
|
53
|
+
const coverage = this.scopedCoverage(await this.coverage.get([this.key, thread]), access);
|
|
54
|
+
if (access.revoked || coverage?.revoked || (coverage?.scopeVersion ?? 0) !== access.version)
|
|
55
|
+
return { messages: [], hasEarlier: false, coverage: { ...coverage, complete: false, revoked: true, scopeVersion: access.version } };
|
|
56
|
+
const records = await this.rows.bulkGet([...new Set(ids)].map(id => [this.key, thread, id]));
|
|
57
|
+
return this.controlPage({ messages: records.flatMap(row => row ? [row.value] : []), hasEarlier: false, coverage });
|
|
58
|
+
});
|
|
59
|
+
return this.finishPage(thread, page);
|
|
60
|
+
}
|
|
46
61
|
/** Migration records never claim coverage or overwrite authoritative rows. */
|
|
47
62
|
async importLegacy(thread, messages) {
|
|
48
63
|
if (messages.length > 128)
|
|
@@ -173,13 +188,19 @@ export class IndexedDbMessageStore {
|
|
|
173
188
|
const events = page.messages.flatMap(message => messageControls(message, this.scope, this.key));
|
|
174
189
|
const old = events.length ? await this.controls.bulkGet(events.map(controlKey)) : [];
|
|
175
190
|
const pendingControls = events.filter((event, index) => newerControl(old[index], event) !== old[index]);
|
|
176
|
-
const
|
|
191
|
+
const ids = new Set(page.messages.map(message => message.id));
|
|
192
|
+
const references = [...new Set(page.messages.flatMap(quotedMessageIds))].filter(id => !ids.has(id));
|
|
193
|
+
const thread = page.messages[0]?.threadId;
|
|
194
|
+
const targets = thread && references.length ? (await this.rows.bulkGet(references.map(id => [this.key, thread, id]))).flatMap(row => row ? [row.value] : []) : [];
|
|
195
|
+
const selected = [...page.messages, ...targets];
|
|
196
|
+
const facts = await this.controls.where("[scope+thread+target]").anyOf(selected.map(message => [this.key, message.threadId, message.id])).toArray();
|
|
177
197
|
const latest = new Map();
|
|
178
198
|
for (const event of [...facts, ...events]) {
|
|
179
199
|
const key = JSON.stringify(controlKey(event));
|
|
180
200
|
latest.set(key, newerControl(latest.get(key), event));
|
|
181
201
|
}
|
|
182
|
-
|
|
202
|
+
const controlled = applyMessageControls(selected, [...latest.values()]);
|
|
203
|
+
return { ...page, messages: redactMessageQuotes(controlled.slice(0, page.messages.length), controlled), pendingControls };
|
|
183
204
|
}
|
|
184
205
|
async finishPage(thread, page) {
|
|
185
206
|
const { pendingControls, ...result } = page;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pmate/chat-sdk",
|
|
3
|
-
"version": "0.12.0-next.
|
|
3
|
+
"version": "0.12.0-next.41",
|
|
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",
|
|
@@ -19,6 +19,11 @@
|
|
|
19
19
|
"import": "./lib/index.js",
|
|
20
20
|
"default": "./lib/index.js"
|
|
21
21
|
},
|
|
22
|
+
"./bootstrap": {
|
|
23
|
+
"types": "./lib/runtime/messageLaunch.d.ts",
|
|
24
|
+
"import": "./lib/runtime/messageLaunch.js",
|
|
25
|
+
"default": "./lib/runtime/messageLaunch.js"
|
|
26
|
+
},
|
|
22
27
|
"./runtime": {
|
|
23
28
|
"types": "./lib/runtime/index.d.ts",
|
|
24
29
|
"import": "./lib/runtime/index.js",
|