@pmate/chat-sdk 0.12.0-next.7 → 0.12.0-next.71
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 +170 -81
- package/lib/advanced.d.ts +10 -0
- package/lib/advanced.js +10 -0
- package/lib/chat.d.ts +5 -0
- package/lib/chat.js +11 -0
- package/lib/chatContract.d.ts +148 -0
- package/lib/chatContract.js +1 -0
- package/lib/chatError.d.ts +11 -0
- package/lib/chatError.js +13 -0
- package/lib/chatTypes.d.ts +121 -0
- package/lib/chatTypes.js +1 -0
- package/lib/commands.d.ts +20 -0
- package/lib/commands.js +12 -0
- package/lib/components/ChatInputDesktop.js +5 -4
- package/lib/components/ChatInputMobile.js +5 -4
- package/lib/components/CommandMenu.d.ts +15 -0
- package/lib/components/CommandMenu.js +10 -0
- package/lib/credentials.d.ts +26 -0
- package/lib/credentials.js +110 -0
- package/lib/fileMetadata.d.ts +9 -0
- package/lib/fileMetadata.js +18 -0
- package/lib/framework.d.ts +11 -0
- package/lib/framework.js +12 -0
- package/lib/history.d.ts +4 -0
- package/lib/history.js +8 -0
- package/lib/hooks/useChatSnapshot.d.ts +4 -0
- package/lib/hooks/useChatSnapshot.js +9 -0
- package/lib/hooks/useComposerSuggestions.d.ts +50 -0
- package/lib/hooks/useComposerSuggestions.js +70 -0
- package/lib/index.d.ts +5 -0
- package/lib/index.js +5 -0
- package/lib/internal/chatEngine.d.ts +131 -0
- package/lib/internal/chatEngine.js +280 -0
- package/lib/internal/chatRegistry.d.ts +4 -0
- package/lib/internal/chatRegistry.js +4 -0
- package/lib/internal/createPublicChat.d.ts +4 -0
- package/lib/internal/createPublicChat.js +52 -0
- package/lib/messageView.d.ts +6 -0
- package/lib/messageView.js +103 -0
- package/lib/runtime/batchedReadIndex.d.ts +16 -3
- package/lib/runtime/batchedReadIndex.js +50 -4
- package/lib/runtime/cachedMessage.d.ts +12 -0
- package/lib/runtime/cachedMessage.js +18 -0
- package/lib/runtime/chatClient.d.ts +2 -0
- package/lib/runtime/chatClient.js +8 -3
- package/lib/runtime/chatError.d.ts +3 -0
- package/lib/runtime/chatError.js +15 -0
- package/lib/runtime/chatSession.d.ts +127 -13
- package/lib/runtime/chatSession.js +727 -40
- package/lib/runtime/connectivity.d.ts +4 -0
- package/lib/runtime/connectivity.js +8 -0
- package/lib/runtime/conversationDirectory.d.ts +37 -0
- package/lib/runtime/conversationDirectory.js +116 -0
- package/lib/runtime/conversationRead.d.ts +13 -0
- package/lib/runtime/conversationRead.js +34 -0
- package/lib/runtime/conversationReading.d.ts +28 -0
- package/lib/runtime/conversationReading.js +47 -0
- package/lib/runtime/createChat.d.ts +11 -7
- package/lib/runtime/createChat.js +46 -23
- package/lib/runtime/durableSender.d.ts +97 -14
- package/lib/runtime/durableSender.js +786 -71
- package/lib/runtime/groupClient.d.ts +76 -0
- package/lib/runtime/groupClient.js +325 -0
- package/lib/runtime/index.d.ts +7 -0
- package/lib/runtime/index.js +5 -0
- package/lib/runtime/messageClient.d.ts +35 -8
- package/lib/runtime/messageClient.js +364 -66
- package/lib/runtime/messageControls.d.ts +19 -0
- package/lib/runtime/messageControls.js +44 -0
- package/lib/runtime/messageLaunch.d.ts +44 -0
- package/lib/runtime/messageLaunch.js +137 -0
- package/lib/runtime/messageQuotes.d.ts +3 -0
- package/lib/runtime/messageQuotes.js +17 -0
- package/lib/runtime/messageReaders.d.ts +12 -1
- package/lib/runtime/messageReaders.js +67 -3
- package/lib/runtime/messageStore.d.ts +40 -2
- package/lib/runtime/messageStore.js +227 -11
- package/lib/runtime/messageTimeline.d.ts +50 -0
- package/lib/runtime/messageTimeline.js +120 -0
- package/lib/runtime/outboxSchema.d.ts +5 -0
- package/lib/runtime/outboxSchema.js +59 -0
- package/lib/runtime/outgoingContent.d.ts +89 -0
- package/lib/runtime/outgoingContent.js +113 -0
- package/lib/runtime/readBatchJournal.d.ts +35 -0
- package/lib/runtime/readBatchJournal.js +210 -0
- package/lib/runtime/readCounts.d.ts +15 -1
- package/lib/runtime/readCounts.js +97 -9
- package/lib/runtime/readIndex.d.ts +68 -7
- package/lib/runtime/readIndex.js +460 -22
- package/lib/runtime/readSchema.d.ts +2 -0
- package/lib/runtime/readSchema.js +58 -0
- package/lib/runtime/readSync.d.ts +1 -0
- package/lib/runtime/readSync.js +26 -9
- package/lib/runtime/readingClient.d.ts +19 -0
- package/lib/runtime/readingClient.js +131 -19
- package/lib/runtime/readingContent.d.ts +5 -0
- package/lib/runtime/readingContent.js +23 -0
- package/lib/runtime/responseCache.d.ts +59 -0
- package/lib/runtime/responseCache.js +232 -0
- package/lib/runtime/socialReadClient.d.ts +4 -2
- package/lib/runtime/socialReadClient.js +12 -8
- package/lib/runtime/voiceUpload.d.ts +2 -0
- package/lib/runtime/voiceUpload.js +27 -0
- package/lib/types/chatInput.d.ts +3 -0
- package/package.json +27 -2
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { StoredMessage, MessageScope } from "./messageStore.js";
|
|
2
|
+
export type MessageControl = {
|
|
3
|
+
scope: string;
|
|
4
|
+
thread: string;
|
|
5
|
+
target: string;
|
|
6
|
+
kind: "recall" | "reaction";
|
|
7
|
+
actor: string;
|
|
8
|
+
emoji: string;
|
|
9
|
+
id: string;
|
|
10
|
+
timestamp: number;
|
|
11
|
+
revision: number;
|
|
12
|
+
active: boolean;
|
|
13
|
+
};
|
|
14
|
+
export type ControlKey = [string, string, string, string, string, string];
|
|
15
|
+
export declare const controlKey: (event: MessageControl) => ControlKey;
|
|
16
|
+
export declare function newerControl(old: MessageControl | undefined, next: MessageControl): MessageControl;
|
|
17
|
+
/** Body-free proofs retained independently of the visible history window. */
|
|
18
|
+
export declare function messageControls(message: StoredMessage, scope: MessageScope, key: string): MessageControl[];
|
|
19
|
+
export declare function applyMessageControls<T extends StoredMessage>(messages: T[], controls: readonly MessageControl[]): T[];
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
export const controlKey = (event) => [event.scope, event.thread, event.target, event.kind, event.actor, event.emoji];
|
|
2
|
+
export function newerControl(old, next) {
|
|
3
|
+
return !old || next.revision > old.revision || next.revision === old.revision && (next.timestamp > old.timestamp || next.timestamp === old.timestamp && next.id > old.id) ? next : old;
|
|
4
|
+
}
|
|
5
|
+
const identifier = (value) => typeof value === "string" && value.length > 0 && value.length <= 256;
|
|
6
|
+
/** Body-free proofs retained independently of the visible history window. */
|
|
7
|
+
export function messageControls(message, scope, key) {
|
|
8
|
+
const row = message;
|
|
9
|
+
if (row.appId !== undefined && row.appId !== scope.appId || row.teamId !== undefined && row.teamId !== scope.teamId || !identifier(row.from))
|
|
10
|
+
return [];
|
|
11
|
+
const apps = row.body;
|
|
12
|
+
const app = apps?.content?.appId ?? apps?.payload?.appId;
|
|
13
|
+
if (app !== undefined && app !== scope.appId)
|
|
14
|
+
return [];
|
|
15
|
+
if (row.recalled || row.legacyDisplay?.recalled)
|
|
16
|
+
return [{ scope: key, thread: message.threadId, target: message.id, kind: "recall", actor: row.from, emoji: "", id: message.id, timestamp: message.timestamp, revision: 0, active: true }];
|
|
17
|
+
const body = row.body;
|
|
18
|
+
const content = body?.kind === "message" && body.content?.appId === scope.appId ? body.content : undefined;
|
|
19
|
+
const target = body?.kind === "recall" && row.appId === scope.appId ? body.targetMessageId : content?.recall?.messageId;
|
|
20
|
+
const base = { scope: key, thread: message.threadId, actor: row.from, id: message.id, timestamp: message.timestamp };
|
|
21
|
+
if (identifier(target))
|
|
22
|
+
return [{ ...base, target, kind: "recall", emoji: "", revision: 0, active: true }];
|
|
23
|
+
const reaction = content?.reaction;
|
|
24
|
+
if (body?.contentKind === "text" && reaction?.version === 1 && identifier(reaction.messageId) && identifier(reaction.emojiId) && Number.isSafeInteger(reaction.revision) && (reaction.revision ?? 0) >= 1 && typeof reaction.active === "boolean")
|
|
25
|
+
return [{ ...base, target: reaction.messageId, kind: "reaction", emoji: reaction.emojiId, revision: reaction.revision, active: reaction.active }];
|
|
26
|
+
return [];
|
|
27
|
+
}
|
|
28
|
+
export function applyMessageControls(messages, controls) {
|
|
29
|
+
const grouped = new Map();
|
|
30
|
+
for (const event of controls) {
|
|
31
|
+
const list = grouped.get(event.target) ?? [];
|
|
32
|
+
list.push(event);
|
|
33
|
+
grouped.set(event.target, list);
|
|
34
|
+
}
|
|
35
|
+
return messages.map(message => {
|
|
36
|
+
const facts = grouped.get(message.id);
|
|
37
|
+
if (!facts?.length)
|
|
38
|
+
return message;
|
|
39
|
+
const row = message;
|
|
40
|
+
const recalled = message.recalled || facts.some(event => event.kind === "recall" && event.actor === row.from);
|
|
41
|
+
const reactionEvents = facts.filter(event => event.kind === "reaction").map(event => ({ id: event.id, messageId: event.target, threadId: event.thread, from: event.actor, emojiId: event.emoji, active: event.active, revision: event.revision, timestamp: event.timestamp }));
|
|
42
|
+
return { ...message, ...(recalled ? { recalled: true } : {}), ...(reactionEvents.length ? { reactionEvents: [...(row.reactionEvents ?? []), ...reactionEvents] } : {}) };
|
|
43
|
+
});
|
|
44
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { type ChatMessage } from "../messageView.js";
|
|
2
|
+
import { type MessageScope } from "./messageStore.js";
|
|
3
|
+
import { type CachedChatMessage } from "./cachedMessage.js";
|
|
4
|
+
import { type TimelineItem } from "./messageTimeline.js";
|
|
5
|
+
export type MessageLaunchMetadata = {
|
|
6
|
+
title: string;
|
|
7
|
+
subtitle?: string;
|
|
8
|
+
kind: "dm" | "group";
|
|
9
|
+
peerId: string;
|
|
10
|
+
};
|
|
11
|
+
export type MessageLaunchView = {
|
|
12
|
+
openId: string;
|
|
13
|
+
threadId: string;
|
|
14
|
+
metadata: MessageLaunchMetadata;
|
|
15
|
+
images: Record<string, string>;
|
|
16
|
+
messages: readonly ChatMessage[]; /** @deprecated Advanced compatibility projection. */
|
|
17
|
+
timeline: readonly TimelineItem<CachedChatMessage>[];
|
|
18
|
+
};
|
|
19
|
+
type Options = {
|
|
20
|
+
indexedDB?: IDBFactory;
|
|
21
|
+
name?: string;
|
|
22
|
+
messagesName?: string;
|
|
23
|
+
outboxName?: string;
|
|
24
|
+
};
|
|
25
|
+
/** A short-lived ticket contains references and display metadata, never message bodies. */
|
|
26
|
+
export declare function saveMessageLaunch(input: {
|
|
27
|
+
scope: MessageScope;
|
|
28
|
+
openId: string;
|
|
29
|
+
threadId: string;
|
|
30
|
+
ids: readonly string[];
|
|
31
|
+
metadata: MessageLaunchMetadata;
|
|
32
|
+
images?: Readonly<Record<string, string>>;
|
|
33
|
+
}, options?: Options): Promise<boolean>;
|
|
34
|
+
/** Observe one local handoff through canonical message/control and outbox stores.
|
|
35
|
+
* This host never creates a transport, marks reading, or writes a second body cache. */
|
|
36
|
+
export declare function watchMessageLaunch(input: {
|
|
37
|
+
scope: MessageScope;
|
|
38
|
+
openId: string;
|
|
39
|
+
threadId: string;
|
|
40
|
+
}, onChange: (view: MessageLaunchView | undefined) => void, onError: (error: unknown) => void, options?: Options): {
|
|
41
|
+
ready: Promise<void>;
|
|
42
|
+
close: () => void;
|
|
43
|
+
};
|
|
44
|
+
export {};
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { createMessagePresenter } from "../messageView.js";
|
|
2
|
+
import { Dexie } from "dexie";
|
|
3
|
+
import { IndexedDbMessageStore, messageScopeKey, mergeStoredMessage } from "./messageStore.js";
|
|
4
|
+
import { DurableChatSender } from "./durableSender.js";
|
|
5
|
+
import { decodeCachedChatMessage } from "./cachedMessage.js";
|
|
6
|
+
import { projectMessageTimeline } from "./messageTimeline.js";
|
|
7
|
+
import { WindowedTasks } from "./coalescer.js";
|
|
8
|
+
function tickets(options) {
|
|
9
|
+
const db = new Dexie(options.name ?? "pmate-chat-launches", { indexedDB: options.indexedDB ?? globalThis.indexedDB, IDBKeyRange: globalThis.IDBKeyRange });
|
|
10
|
+
db.version(1).stores({ tickets: "[scope+openId],scope,expiresAt" });
|
|
11
|
+
return { db, rows: db.table("tickets") };
|
|
12
|
+
}
|
|
13
|
+
function messages(scope, options) { return new IndexedDbMessageStore(scope, mergeStoredMessage, { indexedDB: options.indexedDB, name: options.messagesName }); }
|
|
14
|
+
function valid(value) { return typeof value === "string" && value.length > 0 && value.length <= 256 && !/[\u0000-\u001f]/.test(value); }
|
|
15
|
+
/** A short-lived ticket contains references and display metadata, never message bodies. */
|
|
16
|
+
export async function saveMessageLaunch(input, options = {}) {
|
|
17
|
+
const { scope, openId, threadId } = input;
|
|
18
|
+
if (!Object.values(scope).every(valid) || !valid(openId) || !valid(threadId) || input.ids.length > 30 || input.ids.some(id => !valid(id)))
|
|
19
|
+
throw new Error("INVALID_MESSAGE_LAUNCH");
|
|
20
|
+
if (!["dm", "group"].includes(input.metadata.kind) || !valid(input.metadata.peerId) || typeof input.metadata.title !== "string")
|
|
21
|
+
throw new Error("INVALID_MESSAGE_LAUNCH");
|
|
22
|
+
const store = messages(scope, options), { db, rows } = tickets(options);
|
|
23
|
+
try {
|
|
24
|
+
const page = await store.readSelected(threadId, []), coverage = page.coverage;
|
|
25
|
+
if (!coverage?.complete || coverage.revoked || !input.ids.length)
|
|
26
|
+
return false;
|
|
27
|
+
const images = {};
|
|
28
|
+
for (const [url, data] of Object.entries(input.images ?? {}).slice(-4))
|
|
29
|
+
if (url.length <= 2048 && data.length <= 65536 && /^data:image\/(webp|png);base64,[A-Za-z0-9+/=]+$/.test(data))
|
|
30
|
+
images[url] = data;
|
|
31
|
+
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 };
|
|
32
|
+
await db.transaction("rw", rows, async () => {
|
|
33
|
+
await rows.where("expiresAt").below(Date.now()).limit(8).delete();
|
|
34
|
+
await rows.put(ticket);
|
|
35
|
+
const excess = await rows.count() - 8;
|
|
36
|
+
if (excess > 0)
|
|
37
|
+
await rows.bulkDelete(await rows.orderBy("expiresAt").limit(excess).primaryKeys());
|
|
38
|
+
});
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
finally {
|
|
42
|
+
store.close();
|
|
43
|
+
db.close();
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
/** Observe one local handoff through canonical message/control and outbox stores.
|
|
47
|
+
* This host never creates a transport, marks reading, or writes a second body cache. */
|
|
48
|
+
export function watchMessageLaunch(input, onChange, onError, options = {}) {
|
|
49
|
+
const { scope, openId, threadId } = input, key = messageScopeKey(scope), store = messages(scope, options), { db, rows } = tickets(options);
|
|
50
|
+
let closed = false, generation = 0, ticket, timer;
|
|
51
|
+
const present = createMessagePresenter();
|
|
52
|
+
const channels = [];
|
|
53
|
+
const stop = () => { if (closed)
|
|
54
|
+
return; closed = true; generation++; clearTimeout(timer); tasks.close(); channels.forEach(channel => channel.close()); store.close(); db.close(); };
|
|
55
|
+
const hide = () => { try {
|
|
56
|
+
if (!closed)
|
|
57
|
+
onChange(undefined);
|
|
58
|
+
}
|
|
59
|
+
finally {
|
|
60
|
+
stop();
|
|
61
|
+
} };
|
|
62
|
+
const fail = (error) => { if (!closed) {
|
|
63
|
+
try {
|
|
64
|
+
onError(error);
|
|
65
|
+
}
|
|
66
|
+
finally {
|
|
67
|
+
hide();
|
|
68
|
+
}
|
|
69
|
+
} };
|
|
70
|
+
const refresh = async () => {
|
|
71
|
+
const run = ++generation;
|
|
72
|
+
if (!ticket || closed)
|
|
73
|
+
return;
|
|
74
|
+
if (ticket.expiresAt <= Date.now()) {
|
|
75
|
+
hide();
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
const page = await store.readSelected(threadId, ticket.ids), coverage = page.coverage;
|
|
79
|
+
if (closed || run !== generation)
|
|
80
|
+
return;
|
|
81
|
+
if (!coverage?.complete || coverage.revoked || (coverage.scopeVersion ?? 0) !== ticket.scopeVersion || (coverage.resetVersion ?? 0) !== ticket.resetVersion) {
|
|
82
|
+
hide();
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
const outgoing = await DurableChatSender.readSelected(scope, threadId, ticket.ids, ticket.scopeVersion, { indexedDB: options.indexedDB, name: options.outboxName });
|
|
86
|
+
const guard = (await store.readSelected(threadId, [])).coverage;
|
|
87
|
+
if (closed || run !== generation)
|
|
88
|
+
return;
|
|
89
|
+
if (!guard?.complete || guard.revoked || (guard.scopeVersion ?? 0) !== ticket.scopeVersion || (guard.resetVersion ?? 0) !== ticket.resetVersion) {
|
|
90
|
+
hide();
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
const timeline = projectMessageTimeline({ appId: scope.appId, profileId: scope.profileId, threadId, messages: page.messages, outgoing, decode: message => decodeCachedChatMessage(message, scope), reading: {} }).slice(-30);
|
|
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);
|
|
97
|
+
};
|
|
98
|
+
const tasks = new WindowedTasks(16, fail);
|
|
99
|
+
if (typeof BroadcastChannel !== "undefined") {
|
|
100
|
+
const cache = new BroadcastChannel(`pmate-chat:${key}${options.messagesName && options.messagesName !== "pmate-chat-messages" ? `:${options.messagesName}` : ""}`);
|
|
101
|
+
cache.onmessage = event => {
|
|
102
|
+
if (event.data?.type === "scope-revoked") {
|
|
103
|
+
if (ticket && Number.isSafeInteger(event.data.version) && event.data.version > ticket.scopeVersion)
|
|
104
|
+
hide();
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
if (event.data?.type === "revoked" && event.data.thread === threadId) {
|
|
108
|
+
if (ticket && Number.isSafeInteger(event.data.version) && (event.data.scopeVersion ?? 0) === ticket.scopeVersion && event.data.version > ticket.resetVersion)
|
|
109
|
+
hide();
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
if (event.data === threadId || event.data?.thread === threadId)
|
|
113
|
+
tasks.schedule("view", refresh);
|
|
114
|
+
};
|
|
115
|
+
channels.push(cache);
|
|
116
|
+
const outbox = new BroadcastChannel(`pmate-chat-outbox:${key}:${options.outboxName ?? "pmate-chat-outbox"}`);
|
|
117
|
+
outbox.onmessage = () => tasks.schedule("view", refresh);
|
|
118
|
+
channels.push(outbox);
|
|
119
|
+
}
|
|
120
|
+
const ready = (async () => {
|
|
121
|
+
ticket = await db.transaction("rw", rows, async () => {
|
|
122
|
+
const value = await rows.get([key, openId]);
|
|
123
|
+
if (value)
|
|
124
|
+
await rows.delete([key, openId]);
|
|
125
|
+
return value;
|
|
126
|
+
});
|
|
127
|
+
if (closed)
|
|
128
|
+
return;
|
|
129
|
+
if (!ticket || ticket.scope !== key || ticket.threadId !== threadId || ticket.expiresAt <= Date.now() || ticket.ids.length > 30) {
|
|
130
|
+
hide();
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
timer = setTimeout(hide, Math.max(0, ticket.expiresAt - Date.now()));
|
|
134
|
+
await refresh();
|
|
135
|
+
})().catch(fail);
|
|
136
|
+
return { ready, close: stop };
|
|
137
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { type ResponseCacheAccess } from "./responseCache.js";
|
|
2
|
+
import { type MessageScope } from "./messageStore.js";
|
|
1
3
|
export type MessageReadersPage = {
|
|
2
4
|
appId: string;
|
|
3
5
|
threadId: string;
|
|
@@ -21,6 +23,9 @@ export type MessageReadersOptions = {
|
|
|
21
23
|
token: string;
|
|
22
24
|
hubHttpUrl: string;
|
|
23
25
|
fetch?: typeof fetch;
|
|
26
|
+
scope?: MessageScope;
|
|
27
|
+
access?: ResponseCacheAccess;
|
|
28
|
+
onError?: (error: unknown) => void;
|
|
24
29
|
};
|
|
25
30
|
export declare class MessageReadersError extends Error {
|
|
26
31
|
readonly status: number;
|
|
@@ -32,8 +37,14 @@ export declare class MessageReadersClient {
|
|
|
32
37
|
private options;
|
|
33
38
|
private pending;
|
|
34
39
|
private lifetime;
|
|
40
|
+
private cache?;
|
|
41
|
+
private revoked;
|
|
42
|
+
private generation;
|
|
35
43
|
constructor(options: MessageReadersOptions);
|
|
36
44
|
getReaders(query: MessageReadersQuery, signal?: AbortSignal): Promise<MessageReadersPage>;
|
|
45
|
+
private cachedRequest;
|
|
37
46
|
private request;
|
|
38
|
-
|
|
47
|
+
withdraw(): void;
|
|
48
|
+
retireScope(version: number): Promise<void>;
|
|
49
|
+
close(): Promise<void>;
|
|
39
50
|
}
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { isOffline } from "./connectivity.js";
|
|
2
|
+
import { IndexedDbResponseCache, isResponseCacheAuthorityError, assertResponseCacheAccess } from "./responseCache.js";
|
|
3
|
+
import { messageScopeKey } from "./messageStore.js";
|
|
1
4
|
export class MessageReadersError extends Error {
|
|
2
5
|
status;
|
|
3
6
|
forbidden;
|
|
@@ -12,7 +15,17 @@ export class MessageReadersClient {
|
|
|
12
15
|
options;
|
|
13
16
|
pending = new Map();
|
|
14
17
|
lifetime = new AbortController();
|
|
15
|
-
|
|
18
|
+
cache;
|
|
19
|
+
revoked = false;
|
|
20
|
+
generation = 0;
|
|
21
|
+
constructor(options) {
|
|
22
|
+
this.options = { ...options };
|
|
23
|
+
if (options.scope) {
|
|
24
|
+
if (options.scope.appId !== options.appId || options.scope.profileId !== options.profileId)
|
|
25
|
+
throw new Error("MESSAGE_READERS_SCOPE_MISMATCH");
|
|
26
|
+
this.cache = new IndexedDbResponseCache(messageScopeKey(options.scope), { access: options.access });
|
|
27
|
+
}
|
|
28
|
+
}
|
|
16
29
|
getReaders(query, signal) {
|
|
17
30
|
if (this.lifetime.signal.aborted)
|
|
18
31
|
return Promise.reject(new Error("MESSAGE_READERS_CLOSED"));
|
|
@@ -24,7 +37,7 @@ export class MessageReadersClient {
|
|
|
24
37
|
const key = JSON.stringify([input.threadId, input.messageId, input.afterProfileId, input.revision, input.limit]);
|
|
25
38
|
let task = this.pending.get(key);
|
|
26
39
|
if (!task) {
|
|
27
|
-
task = this.
|
|
40
|
+
task = this.cachedRequest(input, key).finally(() => { if (this.pending.get(key) === task)
|
|
28
41
|
this.pending.delete(key); });
|
|
29
42
|
this.pending.set(key, task);
|
|
30
43
|
}
|
|
@@ -37,7 +50,54 @@ export class MessageReadersClient {
|
|
|
37
50
|
task.then(resolve, reject).finally(() => signal.removeEventListener("abort", abort));
|
|
38
51
|
});
|
|
39
52
|
}
|
|
53
|
+
async cachedRequest(input, key) {
|
|
54
|
+
// Keep only the standard SDK window. Arbitrary page sizes must not multiply
|
|
55
|
+
// durable cache entries. Revision is part of the key, so changed counts do
|
|
56
|
+
// not reuse a page from a previous membership/read projection.
|
|
57
|
+
const cache = input.limit === 50 ? this.cache : undefined;
|
|
58
|
+
if (!cache)
|
|
59
|
+
return this.request(input);
|
|
60
|
+
const stored = await cache.read(key).catch(error => { if (isResponseCacheAuthorityError(error))
|
|
61
|
+
throw error; this.options.onError?.(error); return undefined; });
|
|
62
|
+
if (this.lifetime.signal.aborted)
|
|
63
|
+
throw new Error("MESSAGE_READERS_CLOSED");
|
|
64
|
+
const generation = this.generation;
|
|
65
|
+
const cached = this.revoked ? undefined : stored?.cached;
|
|
66
|
+
if (cached && (Date.now() - cached.time < 5000 || (typeof navigator !== "undefined" && navigator.onLine === false)))
|
|
67
|
+
return structuredClone(cached.value);
|
|
68
|
+
let value;
|
|
69
|
+
try {
|
|
70
|
+
value = await this.request(input);
|
|
71
|
+
}
|
|
72
|
+
catch (error) {
|
|
73
|
+
if (error instanceof MessageReadersError && error.forbidden) {
|
|
74
|
+
this.revoked = true;
|
|
75
|
+
this.generation++;
|
|
76
|
+
await this.cache?.invalidate().catch(failure => this.options.onError?.(failure));
|
|
77
|
+
throw error;
|
|
78
|
+
}
|
|
79
|
+
if (cached && !this.revoked && generation === this.generation && !this.lifetime.signal.aborted && (error instanceof TypeError || (error instanceof MessageReadersError && error.status >= 500)))
|
|
80
|
+
return structuredClone(cached.value);
|
|
81
|
+
throw error;
|
|
82
|
+
}
|
|
83
|
+
if (generation !== this.generation)
|
|
84
|
+
throw new Error("MESSAGE_READERS_INVALIDATED");
|
|
85
|
+
this.revoked = false;
|
|
86
|
+
if (value.resetRequired)
|
|
87
|
+
await cache?.invalidate().catch(error => this.options.onError?.(error));
|
|
88
|
+
else if (stored)
|
|
89
|
+
await cache?.write(key, value, stored.epoch).catch(error => { if (isResponseCacheAuthorityError(error))
|
|
90
|
+
throw error; this.options.onError?.(error); });
|
|
91
|
+
if (this.lifetime.signal.aborted)
|
|
92
|
+
throw new Error("MESSAGE_READERS_CLOSED");
|
|
93
|
+
return value;
|
|
94
|
+
}
|
|
40
95
|
async request(input) {
|
|
96
|
+
const scopeVersion = this.options.access ? await assertResponseCacheAccess(this.options.access) : undefined;
|
|
97
|
+
if (this.lifetime.signal.aborted)
|
|
98
|
+
throw new Error("MESSAGE_READERS_CLOSED");
|
|
99
|
+
if (isOffline())
|
|
100
|
+
throw new MessageReadersError(0, "MESSAGE_READERS_OFFLINE_UNAVAILABLE");
|
|
41
101
|
const { token, fetch: fetcher, hubHttpUrl, appId, profileId } = this.options;
|
|
42
102
|
const controller = new AbortController();
|
|
43
103
|
const cancel = () => controller.abort(this.lifetime.signal.reason);
|
|
@@ -60,6 +120,8 @@ export class MessageReadersClient {
|
|
|
60
120
|
throw new Error("INVALID_MESSAGE_READERS_RESPONSE");
|
|
61
121
|
if (this.lifetime.signal.aborted)
|
|
62
122
|
throw new Error("MESSAGE_READERS_CLOSED");
|
|
123
|
+
if (this.options.access)
|
|
124
|
+
await assertResponseCacheAccess(this.options.access, scopeVersion);
|
|
63
125
|
return data;
|
|
64
126
|
}
|
|
65
127
|
finally {
|
|
@@ -67,5 +129,7 @@ export class MessageReadersClient {
|
|
|
67
129
|
this.lifetime.signal.removeEventListener("abort", cancel);
|
|
68
130
|
}
|
|
69
131
|
}
|
|
70
|
-
|
|
132
|
+
withdraw() { this.lifetime.abort(); this.pending.clear(); }
|
|
133
|
+
retireScope(version) { return this.cache?.retire(version) ?? Promise.resolve(); }
|
|
134
|
+
close() { this.lifetime.abort(); this.pending.clear(); return this.cache?.close().catch(error => this.options.onError?.(error)) ?? Promise.resolve(); }
|
|
71
135
|
}
|
|
@@ -18,6 +18,12 @@ export type MessageCoverage = {
|
|
|
18
18
|
syncedAt?: number;
|
|
19
19
|
resetVersion?: number;
|
|
20
20
|
revoked?: boolean;
|
|
21
|
+
scopeVersion?: number;
|
|
22
|
+
};
|
|
23
|
+
export type MessageScopeAccess = {
|
|
24
|
+
scope: string;
|
|
25
|
+
version: number;
|
|
26
|
+
revoked: boolean;
|
|
21
27
|
};
|
|
22
28
|
type CoverageRow = MessageCoverage & {
|
|
23
29
|
scope: string;
|
|
@@ -27,10 +33,14 @@ export declare const messageScopeKey: (scope: MessageScope) => string;
|
|
|
27
33
|
/** Normalized browser cache. Data and coverage advance together in each commit.
|
|
28
34
|
* Merge runs inside the write transaction against the latest cross-tab state. */
|
|
29
35
|
export declare class IndexedDbMessageStore<T extends StoredMessage> {
|
|
36
|
+
private scope;
|
|
30
37
|
private merge;
|
|
31
38
|
private db;
|
|
32
39
|
private rows;
|
|
40
|
+
private legacy;
|
|
33
41
|
private coverage;
|
|
42
|
+
private accessRows;
|
|
43
|
+
private controls;
|
|
34
44
|
private key;
|
|
35
45
|
constructor(scope: MessageScope, merge: (previous: T | undefined, incoming: T) => T, options?: {
|
|
36
46
|
indexedDB?: IDBFactory;
|
|
@@ -43,15 +53,43 @@ export declare class IndexedDbMessageStore<T extends StoredMessage> {
|
|
|
43
53
|
}): Promise<{
|
|
44
54
|
messages: T[];
|
|
45
55
|
hasEarlier: boolean;
|
|
46
|
-
coverage:
|
|
56
|
+
coverage: MessageCoverage | undefined;
|
|
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
|
+
}>;
|
|
64
|
+
/** Migration records never claim coverage or overwrite authoritative rows. */
|
|
65
|
+
importLegacy(thread: string, messages: T[]): Promise<number>;
|
|
66
|
+
/** Bounded fallback only; callers must report an incomplete window. */
|
|
67
|
+
readFallback(thread: string, limit?: number, before?: {
|
|
68
|
+
timestamp: number;
|
|
69
|
+
id: string;
|
|
70
|
+
}): Promise<{
|
|
71
|
+
messages: T[];
|
|
72
|
+
hasEarlier: boolean;
|
|
73
|
+
coverage: MessageCoverage | undefined;
|
|
47
74
|
}>;
|
|
48
75
|
commit(thread: string, messages: T[], coverage?: MessageCoverage, guard?: {
|
|
49
76
|
version: number;
|
|
50
77
|
authoritative?: boolean;
|
|
78
|
+
scopeVersion?: number;
|
|
51
79
|
}): Promise<number>;
|
|
80
|
+
private rememberControls;
|
|
81
|
+
private controlPage;
|
|
82
|
+
private finishPage;
|
|
52
83
|
windows(): Promise<CoverageRow[]>;
|
|
84
|
+
scopeAccess(): Promise<MessageScopeAccess>;
|
|
85
|
+
/** Only fresh, server-confirmed authorization may lift this durable tombstone. */
|
|
86
|
+
authorizeScope(expectedVersion: number): Promise<void>;
|
|
87
|
+
private scopedCoverage;
|
|
88
|
+
private clearScopeRows;
|
|
89
|
+
/** Persist the denial first; failed cleanup must never make cached rows readable. */
|
|
90
|
+
revokeScope(expectedVersion?: number): Promise<number>;
|
|
53
91
|
/** A durable epoch prevents another tab's pre-reset request from restoring rows. */
|
|
54
|
-
resetThread(thread: string, revoked?: boolean): Promise<number>;
|
|
92
|
+
resetThread(thread: string, revoked?: boolean, scopeVersion?: number): Promise<number>;
|
|
55
93
|
removeThread(thread: string): Promise<void>;
|
|
56
94
|
clear(): Promise<void>;
|
|
57
95
|
close(): void;
|