@pmate/chat-sdk 0.12.0-next.4 → 0.12.0-next.40
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 +41 -1
- package/lib/runtime/batchedReadIndex.d.ts +11 -2
- package/lib/runtime/batchedReadIndex.js +21 -1
- package/lib/runtime/chatClient.d.ts +1 -0
- package/lib/runtime/chatClient.js +1 -1
- package/lib/runtime/chatSession.d.ts +78 -8
- package/lib/runtime/chatSession.js +504 -39
- package/lib/runtime/connectivity.d.ts +4 -0
- package/lib/runtime/connectivity.js +8 -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 +3 -0
- package/lib/runtime/createChat.js +18 -5
- package/lib/runtime/durableSender.d.ts +28 -4
- package/lib/runtime/durableSender.js +155 -34
- package/lib/runtime/index.d.ts +3 -0
- package/lib/runtime/index.js +3 -0
- package/lib/runtime/messageClient.d.ts +33 -6
- package/lib/runtime/messageClient.js +443 -71
- package/lib/runtime/messageControls.d.ts +19 -0
- package/lib/runtime/messageControls.js +44 -0
- package/lib/runtime/messageReaders.d.ts +12 -1
- package/lib/runtime/messageReaders.js +67 -3
- package/lib/runtime/messageStore.d.ts +41 -2
- package/lib/runtime/messageStore.js +212 -10
- package/lib/runtime/messageTimeline.d.ts +50 -0
- package/lib/runtime/messageTimeline.js +120 -0
- package/lib/runtime/readCounts.d.ts +15 -1
- package/lib/runtime/readCounts.js +96 -8
- package/lib/runtime/readIndex.d.ts +44 -6
- package/lib/runtime/readIndex.js +238 -11
- package/lib/runtime/readSync.d.ts +1 -0
- package/lib/runtime/readSync.js +21 -8
- package/lib/runtime/readingClient.d.ts +19 -0
- package/lib/runtime/readingClient.js +121 -18
- package/lib/runtime/readingContent.d.ts +5 -0
- package/lib/runtime/readingContent.js +23 -0
- package/lib/runtime/recordCache.d.ts +9 -0
- package/lib/runtime/recordCache.js +62 -5
- package/lib/runtime/responseCache.d.ts +44 -0
- package/lib/runtime/responseCache.js +173 -0
- package/package.json +1 -1
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { isReadingControl } from "./readingContent.js";
|
|
2
|
+
function content(message, appId) {
|
|
3
|
+
const body = message.body;
|
|
4
|
+
return body?.kind === "message" && body.content?.appId === appId ? body : undefined;
|
|
5
|
+
}
|
|
6
|
+
function nativeRecall(message, appId) {
|
|
7
|
+
const body = message.body;
|
|
8
|
+
return message.appId === appId && body?.kind === "recall" && typeof body.targetMessageId === "string" && body.targetMessageId.length > 0 && body.targetMessageId.length <= 256 ? body.targetMessageId : undefined;
|
|
9
|
+
}
|
|
10
|
+
function isControl(message, appId) { return isReadingControl(message.body, appId) || nativeRecall(message, appId) !== undefined; }
|
|
11
|
+
function validReaction(value) {
|
|
12
|
+
if (!value || typeof value !== "object")
|
|
13
|
+
return false;
|
|
14
|
+
const event = value;
|
|
15
|
+
return [event.id, event.messageId, event.from, event.emojiId].every(field => typeof field === "string" && field.length > 0 && field.length <= 256) && typeof event.active === "boolean" && Number.isSafeInteger(event.revision) && event.revision >= 0 && Number.isFinite(event.timestamp);
|
|
16
|
+
}
|
|
17
|
+
/** Derives one display stream without copying bodies or creating another cache.
|
|
18
|
+
* Outgoing rows retain their wire identity; indexed rows take precedence. */
|
|
19
|
+
export function projectMessageTimeline(input) {
|
|
20
|
+
const scoped = (message) => {
|
|
21
|
+
const value = message, body = value.body;
|
|
22
|
+
const app = body?.content?.appId ?? body?.payload?.appId;
|
|
23
|
+
return message.threadId === input.threadId && (value.appId === undefined || value.appId === input.appId) && (app === undefined || app === input.appId);
|
|
24
|
+
};
|
|
25
|
+
const tombstones = new Set(input.messages.filter(message => scoped(message) && (message.recalled || message.legacyDisplay?.recalled)).map(message => message.id));
|
|
26
|
+
const records = new Map();
|
|
27
|
+
for (const pending of input.outgoing) {
|
|
28
|
+
if (pending.threadId !== input.threadId || pending.confirmed)
|
|
29
|
+
continue;
|
|
30
|
+
const message = input.decode(pending.message);
|
|
31
|
+
if (message && scoped(message))
|
|
32
|
+
records.set(message.id, { message, status: pending.status });
|
|
33
|
+
}
|
|
34
|
+
for (const message of input.messages)
|
|
35
|
+
if (scoped(message)) {
|
|
36
|
+
const legacy = message.legacyDisplay;
|
|
37
|
+
if (legacy && records.has(message.id))
|
|
38
|
+
continue;
|
|
39
|
+
const legacyStatus = legacy?.status;
|
|
40
|
+
const status = legacyStatus === "failed" || legacyStatus === "sending" || legacyStatus === "read" ? legacyStatus : "sent";
|
|
41
|
+
records.set(message.id, { message, status });
|
|
42
|
+
}
|
|
43
|
+
const recalled = new Map(), reactions = new Map();
|
|
44
|
+
const addReaction = (event) => {
|
|
45
|
+
const key = JSON.stringify([event.messageId, event.from, event.emojiId]), old = reactions.get(key);
|
|
46
|
+
if (!old || event.revision > old.revision || event.revision === old.revision && (event.timestamp > old.timestamp || event.timestamp === old.timestamp && event.id > old.id))
|
|
47
|
+
reactions.set(key, event);
|
|
48
|
+
};
|
|
49
|
+
for (const { message, status } of records.values()) {
|
|
50
|
+
if (status === "failed" && isControl(message, input.appId))
|
|
51
|
+
continue;
|
|
52
|
+
const envelope = message, body = content(envelope, input.appId), target = nativeRecall(envelope, input.appId) ?? body?.content?.recall?.messageId;
|
|
53
|
+
if (typeof target === "string" && target.length > 0 && target.length <= 256 && envelope.from) {
|
|
54
|
+
if (!recalled.has(target))
|
|
55
|
+
recalled.set(target, new Set());
|
|
56
|
+
recalled.get(target).add(envelope.from);
|
|
57
|
+
}
|
|
58
|
+
const reaction = body?.content?.reaction;
|
|
59
|
+
if (body?.contentKind === "text" && reaction?.version === 1 && typeof reaction.messageId === "string" && typeof reaction.emojiId === "string" && typeof reaction.active === "boolean" && typeof reaction.revision === "number" && reaction.revision >= 1 && envelope.from) {
|
|
60
|
+
const event = { id: message.id, messageId: reaction.messageId, from: envelope.from, emojiId: reaction.emojiId, active: reaction.active, revision: reaction.revision, timestamp: message.timestamp };
|
|
61
|
+
if (validReaction(event))
|
|
62
|
+
addReaction(event);
|
|
63
|
+
}
|
|
64
|
+
const events = envelope.reactionEvents ?? envelope.legacyDisplay?.reactionEvents;
|
|
65
|
+
if (Array.isArray(events))
|
|
66
|
+
for (const event of events)
|
|
67
|
+
if (validReaction(event) && event.messageId === message.id && (event.threadId === undefined || event.threadId === input.threadId))
|
|
68
|
+
addReaction(event);
|
|
69
|
+
// Old display-only imports may have aggregates but no event IDs. Treat them
|
|
70
|
+
// as revision-zero baselines; any durable control supersedes them.
|
|
71
|
+
const baseline = envelope.legacyDisplay?.reactions;
|
|
72
|
+
if (Array.isArray(baseline))
|
|
73
|
+
for (const value of baseline) {
|
|
74
|
+
if (typeof value?.emojiId !== "string" || !Array.isArray(value.userIds))
|
|
75
|
+
continue;
|
|
76
|
+
for (const from of value.userIds) {
|
|
77
|
+
const event = { id: `legacy:${message.id}`, messageId: message.id, from, emojiId: value.emojiId, active: true, revision: 0, timestamp: 0 };
|
|
78
|
+
if (validReaction(event))
|
|
79
|
+
addReaction(event);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
const groupedEvents = new Map();
|
|
84
|
+
for (const event of reactions.values()) {
|
|
85
|
+
const values = groupedEvents.get(event.messageId) ?? [];
|
|
86
|
+
values.push({ ...event, threadId: input.threadId });
|
|
87
|
+
groupedEvents.set(event.messageId, values);
|
|
88
|
+
}
|
|
89
|
+
for (const values of groupedEvents.values())
|
|
90
|
+
values.sort((a, b) => a.from < b.from ? -1 : a.from > b.from ? 1 : a.emojiId < b.emojiId ? -1 : a.emojiId > b.emojiId ? 1 : 0);
|
|
91
|
+
const grouped = new Map();
|
|
92
|
+
for (const event of reactions.values())
|
|
93
|
+
if (event.active) {
|
|
94
|
+
if (!grouped.has(event.messageId))
|
|
95
|
+
grouped.set(event.messageId, new Map());
|
|
96
|
+
const emojis = grouped.get(event.messageId);
|
|
97
|
+
if (!emojis.has(event.emojiId))
|
|
98
|
+
emojis.set(event.emojiId, new Set());
|
|
99
|
+
emojis.get(event.emojiId).add(event.from);
|
|
100
|
+
}
|
|
101
|
+
const previous = new Map(input.previous?.map(item => [item.id, item]) ?? []);
|
|
102
|
+
const result = [];
|
|
103
|
+
for (const { message, status } of records.values()) {
|
|
104
|
+
const envelope = message;
|
|
105
|
+
if (isControl(envelope, input.appId))
|
|
106
|
+
continue;
|
|
107
|
+
const mine = envelope.from === input.profileId, reading = input.reading[message.id];
|
|
108
|
+
const withdrawn = Boolean(tombstones.has(message.id) || message.recalled || envelope.legacyDisplay?.recalled || envelope.from && recalled.get(message.id)?.has(envelope.from));
|
|
109
|
+
const candidate = input.candidates?.get(message.id);
|
|
110
|
+
const item = { id: message.id, threadId: message.threadId, timestamp: message.timestamp, from: envelope.from, mine, status: !mine && reading ? (reading.read ? "read" : status === "read" ? "sent" : status) : status, recalled: withdrawn, ...(candidate ? { unread: !mine && !withdrawn && (reading ? !reading.read : candidate.unread), attentionEligible: candidate.attentionEligible } : {}),
|
|
111
|
+
message: withdrawn ? undefined : message, reading, reactionEvents: withdrawn ? [] : groupedEvents.get(message.id) ?? [], reactions: withdrawn ? [] : [...(grouped.get(message.id) ?? [])].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(([emojiId, users]) => { const userIds = [...users].sort(); return { emojiId, userIds, mine: userIds.includes(input.profileId) }; }) };
|
|
112
|
+
const old = previous.get(item.id);
|
|
113
|
+
const sameReading = old?.reading === item.reading || old?.reading?.seq === item.reading?.seq && old?.reading?.read === item.reading?.read && old?.reading?.readPolicy === item.reading?.readPolicy;
|
|
114
|
+
const sameReactions = old?.reactions.length === item.reactions.length && old.reactions.every((reaction, index) => { const next = item.reactions[index]; return reaction.emojiId === next.emojiId && reaction.mine === next.mine && reaction.userIds.length === next.userIds.length && reaction.userIds.every((user, i) => user === next.userIds[i]); });
|
|
115
|
+
const sameEvents = old?.reactionEvents.length === item.reactionEvents.length && old.reactionEvents.every((event, index) => { const next = item.reactionEvents[index]; return event.id === next.id && event.from === next.from && event.emojiId === next.emojiId && event.active === next.active && event.revision === next.revision && event.timestamp === next.timestamp && event.threadId === next.threadId && event.messageId === next.messageId; });
|
|
116
|
+
result.push(old && sameEvents && old.message === item.message && old.timestamp === item.timestamp && old.from === item.from && old.mine === item.mine && old.status === item.status && old.recalled === item.recalled && old.unread === item.unread && old.attentionEligible === item.attentionEligible && sameReading && sameReactions ? old : item);
|
|
117
|
+
}
|
|
118
|
+
result.sort((a, b) => a.timestamp - b.timestamp || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
119
|
+
return input.previous?.length === result.length && result.every((item, index) => item === input.previous[index]) ? input.previous : result;
|
|
120
|
+
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { type ResponseCacheAccess } from "./responseCache.js";
|
|
2
|
+
import { type MessageScope } from "./messageStore.js";
|
|
1
3
|
export type ReadCountsResult = {
|
|
2
4
|
appId: string;
|
|
3
5
|
threadId: string;
|
|
@@ -11,6 +13,8 @@ export type ReadCountsOptions = {
|
|
|
11
13
|
clientInstanceId: string;
|
|
12
14
|
hubHttpUrl: string;
|
|
13
15
|
fetch?: typeof fetch;
|
|
16
|
+
scope?: MessageScope;
|
|
17
|
+
access?: ResponseCacheAccess;
|
|
14
18
|
onChange: (value: ReadCountsResult) => void;
|
|
15
19
|
onError?: (error: unknown) => void;
|
|
16
20
|
};
|
|
@@ -25,13 +29,22 @@ export declare class ReadCountsClient {
|
|
|
25
29
|
private generation;
|
|
26
30
|
private retries;
|
|
27
31
|
private closed;
|
|
32
|
+
private closing?;
|
|
28
33
|
private revision;
|
|
29
34
|
private running;
|
|
30
35
|
private dirty;
|
|
31
36
|
private subscribed;
|
|
32
37
|
private subscriptionDenied;
|
|
33
38
|
private subscriptionPending?;
|
|
39
|
+
private cache?;
|
|
40
|
+
private lastResult?;
|
|
41
|
+
private publish;
|
|
42
|
+
private revoked;
|
|
43
|
+
private accessVersion;
|
|
44
|
+
private requestedRevision;
|
|
45
|
+
private force;
|
|
34
46
|
constructor(options: ReadCountsOptions);
|
|
47
|
+
private denyCache;
|
|
35
48
|
private post;
|
|
36
49
|
private subscription;
|
|
37
50
|
private renew;
|
|
@@ -40,5 +53,6 @@ export declare class ReadCountsClient {
|
|
|
40
53
|
reconnect(): void;
|
|
41
54
|
private schedule;
|
|
42
55
|
private refresh;
|
|
43
|
-
|
|
56
|
+
retireScope(version: number): Promise<void>;
|
|
57
|
+
close(): Promise<void>;
|
|
44
58
|
}
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { isOffline, isOfflineNetworkFailure } from "./connectivity.js";
|
|
2
|
+
import { IndexedDbResponseCache, isResponseCacheAuthorityError, assertResponseCacheAccess } from "./responseCache.js";
|
|
3
|
+
import { messageScopeKey } from "./messageStore.js";
|
|
1
4
|
let subscriptionSequence = 0;
|
|
2
5
|
function nextSubscriptionSequence() { subscriptionSequence = Math.max(Date.now() * 1000, subscriptionSequence + 1); return subscriptionSequence; }
|
|
3
6
|
/** One active conversation; fixed event window, bounded batches, no count polling. */
|
|
@@ -11,29 +14,58 @@ export class ReadCountsClient {
|
|
|
11
14
|
generation = 0;
|
|
12
15
|
retries = 0;
|
|
13
16
|
closed = false;
|
|
17
|
+
closing;
|
|
14
18
|
revision = -1;
|
|
15
19
|
running = false;
|
|
16
20
|
dirty = false;
|
|
17
21
|
subscribed = false;
|
|
18
22
|
subscriptionDenied = false;
|
|
19
23
|
subscriptionPending;
|
|
24
|
+
cache;
|
|
25
|
+
lastResult;
|
|
26
|
+
publish(value) {
|
|
27
|
+
if (JSON.stringify(value) === JSON.stringify(this.lastResult))
|
|
28
|
+
return;
|
|
29
|
+
this.lastResult = value;
|
|
30
|
+
this.options.onChange(value);
|
|
31
|
+
}
|
|
32
|
+
revoked = false;
|
|
33
|
+
accessVersion = 0;
|
|
34
|
+
requestedRevision = -1;
|
|
35
|
+
force = false;
|
|
20
36
|
constructor(options) {
|
|
21
37
|
this.options = options;
|
|
38
|
+
if (options.scope) {
|
|
39
|
+
if (options.scope.appId !== options.appId || options.scope.profileId !== options.profileId)
|
|
40
|
+
throw new Error("READ_COUNTS_SCOPE_MISMATCH");
|
|
41
|
+
this.cache = new IndexedDbResponseCache(messageScopeKey(options.scope), { access: options.access });
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
denyCache() {
|
|
45
|
+
this.revoked = true;
|
|
46
|
+
this.accessVersion++;
|
|
47
|
+
this.lastResult = undefined;
|
|
48
|
+
void this.cache?.invalidate().catch(error => this.options.onError?.(error));
|
|
22
49
|
}
|
|
23
50
|
async post(path, body, signal) {
|
|
51
|
+
const scopeVersion = this.options.access ? await assertResponseCacheAccess(this.options.access) : undefined;
|
|
24
52
|
const response = await (this.options.fetch ?? fetch)(`${this.options.hubHttpUrl.replace(/\/$/, "")}${path}`, { method: "POST", headers: { authorization: `Bearer ${this.options.token}`, "content-type": "application/json" }, body: JSON.stringify(body), signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(8000)]) : AbortSignal.timeout(8000), redirect: "error" });
|
|
25
53
|
if (!response.ok)
|
|
26
54
|
throw new Error(`READ_COUNTS_HTTP_${response.status}`);
|
|
27
55
|
const envelope = await response.json();
|
|
28
56
|
if (envelope.success !== true)
|
|
29
57
|
throw new Error("INVALID_READ_COUNTS_RESPONSE");
|
|
58
|
+
if (this.options.access)
|
|
59
|
+
await assertResponseCacheAccess(this.options.access, scopeVersion);
|
|
30
60
|
return envelope.data;
|
|
31
61
|
}
|
|
32
62
|
subscription(threadId, release) {
|
|
63
|
+
if (release && isOffline())
|
|
64
|
+
return Promise.resolve();
|
|
33
65
|
return this.post("/api/social/reading/subscription", { appId: this.options.appId, profileId: this.options.profileId, threadId, clientInstanceId: this.options.clientInstanceId, sequence: nextSubscriptionSequence(), release });
|
|
34
66
|
}
|
|
35
67
|
renew() {
|
|
36
|
-
if (!this.thread || this.closed || this.subscriptionDenied)
|
|
68
|
+
if (!this.thread || this.closed || this.subscriptionDenied || (typeof navigator !== "undefined" && navigator.onLine === false))
|
|
37
69
|
return;
|
|
38
70
|
const threadId = this.thread, generation = this.generation;
|
|
39
71
|
if (this.subscriptionPending?.generation === generation)
|
|
@@ -48,18 +80,29 @@ export class ReadCountsClient {
|
|
|
48
80
|
this.heartbeat = setInterval(() => { if (!this.closed && this.thread === threadId)
|
|
49
81
|
this.renew(); }, 20_000);
|
|
50
82
|
// Recover changes that occurred before a receiver/subscription became ready.
|
|
51
|
-
if (recovered
|
|
83
|
+
if (recovered) {
|
|
84
|
+
// A fresh cache predates the new lease and cannot cover its delivery gap.
|
|
85
|
+
this.force = true;
|
|
52
86
|
this.schedule(0);
|
|
87
|
+
}
|
|
53
88
|
}).catch(error => {
|
|
54
89
|
if (this.closed || generation !== this.generation)
|
|
55
90
|
return;
|
|
56
91
|
this.subscribed = false;
|
|
57
92
|
if (/READ_COUNTS_HTTP_(401|403)/.test(String(error))) {
|
|
58
93
|
this.subscriptionDenied = true;
|
|
94
|
+
this.denyCache();
|
|
59
95
|
clearInterval(this.heartbeat);
|
|
60
96
|
this.heartbeat = undefined;
|
|
61
97
|
}
|
|
62
|
-
|
|
98
|
+
if (isResponseCacheAuthorityError(error)) {
|
|
99
|
+
this.lastResult = undefined;
|
|
100
|
+
this.subscriptionDenied = true;
|
|
101
|
+
clearInterval(this.heartbeat);
|
|
102
|
+
this.heartbeat = undefined;
|
|
103
|
+
}
|
|
104
|
+
if (!isOfflineNetworkFailure(error))
|
|
105
|
+
this.options.onError?.(error);
|
|
63
106
|
}).finally(() => { if (this.subscriptionPending === pending)
|
|
64
107
|
this.subscriptionPending = undefined; });
|
|
65
108
|
this.subscriptionPending = pending;
|
|
@@ -83,9 +126,12 @@ export class ReadCountsClient {
|
|
|
83
126
|
clearTimeout(this.timer);
|
|
84
127
|
clearInterval(this.heartbeat);
|
|
85
128
|
this.timer = undefined;
|
|
129
|
+
this.lastResult = undefined;
|
|
86
130
|
this.thread = threadId;
|
|
87
131
|
this.ids = ids;
|
|
88
132
|
this.revision = -1;
|
|
133
|
+
this.requestedRevision = -1;
|
|
134
|
+
this.force = false;
|
|
89
135
|
this.dirty = false;
|
|
90
136
|
this.retries = 0;
|
|
91
137
|
this.subscribed = false;
|
|
@@ -104,11 +150,13 @@ export class ReadCountsClient {
|
|
|
104
150
|
throw new Error("INVALID_READ_COUNTS_EVENT");
|
|
105
151
|
if (threadId !== this.thread || revision <= this.revision || this.closed)
|
|
106
152
|
return;
|
|
153
|
+
this.requestedRevision = Math.max(this.requestedRevision, revision);
|
|
107
154
|
this.schedule(3000);
|
|
108
155
|
}
|
|
109
156
|
reconnect() {
|
|
110
157
|
if (!this.thread || this.closed)
|
|
111
158
|
return;
|
|
159
|
+
this.force = true;
|
|
112
160
|
this.subscribed = false;
|
|
113
161
|
this.subscriptionDenied = false;
|
|
114
162
|
this.renew();
|
|
@@ -137,22 +185,60 @@ export class ReadCountsClient {
|
|
|
137
185
|
let retryDelay;
|
|
138
186
|
const controller = new AbortController();
|
|
139
187
|
this.controller = controller;
|
|
188
|
+
const accessVersion = this.accessVersion, force = this.force;
|
|
189
|
+
this.force = false;
|
|
190
|
+
const key = JSON.stringify(["counts", threadId, [...ids].sort()]);
|
|
140
191
|
try {
|
|
192
|
+
const stored = this.cache ? await this.cache.read(key).catch(error => { if (isResponseCacheAuthorityError(error))
|
|
193
|
+
throw error; this.options.onError?.(error); return undefined; }) : undefined;
|
|
194
|
+
if (generation !== this.generation || this.closed || accessVersion !== this.accessVersion)
|
|
195
|
+
return;
|
|
196
|
+
const cached = this.revoked || (stored?.cached && stored.cached.value.revision < this.revision) ? undefined : stored?.cached;
|
|
197
|
+
if (cached) {
|
|
198
|
+
this.revision = cached.value.revision;
|
|
199
|
+
this.publish(cached.value);
|
|
200
|
+
const offline = typeof navigator !== "undefined" && navigator.onLine === false;
|
|
201
|
+
if (offline || (!force && this.requestedRevision <= this.revision && Date.now() - cached.time < 5000))
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
if (isOffline())
|
|
205
|
+
return;
|
|
141
206
|
const value = await this.post("/api/social/reading/counts", { appId: this.options.appId, profileId: this.options.profileId, threadId, messageIds: ids }, controller.signal);
|
|
142
|
-
if (generation !== this.generation || this.closed)
|
|
207
|
+
if (generation !== this.generation || this.closed || accessVersion !== this.accessVersion)
|
|
143
208
|
return;
|
|
144
209
|
if (value?.appId !== this.options.appId || value.threadId !== threadId || !Number.isSafeInteger(value.revision) || value.revision < 0 || !value.counts || typeof value.counts !== "object" || ids.some(id => !Number.isSafeInteger(value.counts[id]) || value.counts[id] < 0) || Object.keys(value.counts).some(id => !ids.includes(id)))
|
|
145
210
|
throw new Error("INVALID_READ_COUNTS_RESULT");
|
|
211
|
+
if (value.revision < this.revision)
|
|
212
|
+
return;
|
|
146
213
|
this.retries = 0;
|
|
214
|
+
this.revoked = false;
|
|
215
|
+
if (stored)
|
|
216
|
+
await this.cache?.write(key, value, stored.epoch).catch(error => { if (isResponseCacheAuthorityError(error))
|
|
217
|
+
throw error; this.options.onError?.(error); });
|
|
218
|
+
if (generation !== this.generation || this.closed || accessVersion !== this.accessVersion)
|
|
219
|
+
return;
|
|
147
220
|
this.revision = value.revision;
|
|
148
|
-
this.
|
|
221
|
+
this.publish(value);
|
|
149
222
|
if (!this.subscribed)
|
|
150
223
|
this.renew();
|
|
151
224
|
}
|
|
152
225
|
catch (error) {
|
|
226
|
+
if (isOfflineNetworkFailure(error)) {
|
|
227
|
+
this.dirty = false;
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
153
230
|
if (!controller.signal.aborted && generation === this.generation && !this.closed) {
|
|
231
|
+
if (/READ_COUNTS_HTTP_(401|403)/.test(String(error)))
|
|
232
|
+
this.denyCache();
|
|
233
|
+
if (isResponseCacheAuthorityError(error)) {
|
|
234
|
+
this.lastResult = undefined;
|
|
235
|
+
this.subscriptionDenied = true;
|
|
236
|
+
this.dirty = false;
|
|
237
|
+
clearInterval(this.heartbeat);
|
|
238
|
+
this.heartbeat = undefined;
|
|
239
|
+
}
|
|
154
240
|
this.options.onError?.(error);
|
|
155
|
-
if (!/READ_COUNTS_HTTP_4\d\d|INVALID_READ_COUNTS/.test(String(error)) && this.retries < 3) {
|
|
241
|
+
if (!isResponseCacheAuthorityError(error) && !/READ_COUNTS_HTTP_4\d\d|INVALID_READ_COUNTS/.test(String(error)) && this.retries < 3) {
|
|
156
242
|
retryDelay = 1000 * 2 ** this.retries++;
|
|
157
243
|
this.dirty = true;
|
|
158
244
|
}
|
|
@@ -166,9 +252,10 @@ export class ReadCountsClient {
|
|
|
166
252
|
}
|
|
167
253
|
}
|
|
168
254
|
}
|
|
255
|
+
retireScope(version) { return this.cache?.retire(version) ?? Promise.resolve(); }
|
|
169
256
|
close() {
|
|
170
|
-
if (this.
|
|
171
|
-
return;
|
|
257
|
+
if (this.closing)
|
|
258
|
+
return this.closing;
|
|
172
259
|
const old = this.thread;
|
|
173
260
|
this.closed = true;
|
|
174
261
|
this.generation++;
|
|
@@ -178,5 +265,6 @@ export class ReadCountsClient {
|
|
|
178
265
|
if (old)
|
|
179
266
|
void this.subscription(old, true).catch(() => { });
|
|
180
267
|
this.thread = undefined;
|
|
268
|
+
return this.closing = this.cache?.close().catch(error => this.options.onError?.(error)) ?? Promise.resolve();
|
|
181
269
|
}
|
|
182
270
|
}
|
|
@@ -7,9 +7,16 @@ export type IndexedReadMessage = {
|
|
|
7
7
|
mentions: string[];
|
|
8
8
|
readPolicy: "visible" | "explicit";
|
|
9
9
|
recalled: boolean;
|
|
10
|
-
/**
|
|
10
|
+
/** Legacy standalone readers may retain payloads; unified sessions use the message store. */
|
|
11
11
|
payload: unknown;
|
|
12
|
+
/** Body-free control classification retained by metadata-only reading indexes. */
|
|
13
|
+
readingControl?: boolean;
|
|
12
14
|
};
|
|
15
|
+
/** Retain read semantics without a second durable copy of message bodies. */
|
|
16
|
+
export declare function readingMetadata<T extends {
|
|
17
|
+
appId: string;
|
|
18
|
+
threads: IndexedReadThread[];
|
|
19
|
+
}>(state: T): T;
|
|
13
20
|
export type ReadingOperation = {
|
|
14
21
|
operationId: string;
|
|
15
22
|
threadId: string;
|
|
@@ -72,32 +79,63 @@ export declare function coalesceReadOperations(current: ReadIndexState): (Readin
|
|
|
72
79
|
operationIds: string[];
|
|
73
80
|
})[];
|
|
74
81
|
/** Counts are over the indexed message coverage, not an estimate of missing history. */
|
|
75
|
-
export declare function unreadInThread(state: ReadIndexState, threadId: string): {
|
|
82
|
+
export declare function unreadInThread(state: ReadIndexState, threadId: string, hidden?: ReadonlyMap<string, ReadonlySet<string>>): {
|
|
76
83
|
unreadMessageCount: number;
|
|
77
84
|
unreadMentionCount: number;
|
|
78
85
|
badgeCount: number;
|
|
79
86
|
};
|
|
87
|
+
export type ReadIndexVersions = Readonly<Record<string, number>>;
|
|
88
|
+
export type ReadIndexTransform = (current: ReadIndexState, versions?: ReadIndexVersions) => ReadIndexState;
|
|
89
|
+
export declare function assertReadIndexVersions(expected: ReadIndexVersions, current?: ReadIndexVersions, teamId?: string): void;
|
|
90
|
+
/** Remove only the withdrawn team's original sources, logical conversations and
|
|
91
|
+
* queued observations. Other teams' pending operations must survive. */
|
|
92
|
+
export declare function removeReadTeam(state: ReadIndexState, teamId: string, knownThreadIds?: readonly string[]): ReadIndexState;
|
|
80
93
|
export interface ReadIndexPersistence {
|
|
81
94
|
load(appId: string, profileId: string): Promise<ReadIndexState | undefined>;
|
|
95
|
+
loadVersioned?(appId: string, profileId: string): Promise<{
|
|
96
|
+
state: ReadIndexState | undefined;
|
|
97
|
+
versions: ReadIndexVersions;
|
|
98
|
+
}>;
|
|
99
|
+
loadVersions?(appId: string, profileId: string): Promise<ReadIndexVersions>;
|
|
100
|
+
invalidateTeam?(appId: string, profileId: string, teamId: string, version: number, knownThreadIds?: readonly string[]): Promise<ReadIndexState>;
|
|
82
101
|
/** Transform and persist atomically; UI publishes only the committed result. */
|
|
83
|
-
update(appId: string, profileId: string, transform:
|
|
102
|
+
update(appId: string, profileId: string, transform: ReadIndexTransform): Promise<ReadIndexState>;
|
|
84
103
|
}
|
|
85
104
|
/** Dexie preserves the legacy database/store and outbound [appId, profileId] keys. */
|
|
86
105
|
export declare class IndexedDbReadIndex implements ReadIndexPersistence {
|
|
87
106
|
private storageScope?;
|
|
107
|
+
private migrateLegacy;
|
|
108
|
+
private metadataOnly;
|
|
88
109
|
private database;
|
|
89
110
|
private profiles;
|
|
111
|
+
private threadRows;
|
|
112
|
+
private messageRows;
|
|
113
|
+
private positionRows;
|
|
114
|
+
private normalizedStates;
|
|
115
|
+
private versions;
|
|
90
116
|
private closed;
|
|
91
|
-
constructor(factory?: IDBFactory, name?: string, storageScope?: string | undefined);
|
|
117
|
+
constructor(factory?: IDBFactory, name?: string, storageScope?: string | undefined, migrateLegacy?: boolean, metadataOnly?: boolean);
|
|
92
118
|
private transaction;
|
|
93
119
|
private key;
|
|
120
|
+
private hydrate;
|
|
121
|
+
private loadCurrent;
|
|
122
|
+
private loadStored;
|
|
123
|
+
/** Message bodies are immutable for local read observations: keep them out of
|
|
124
|
+
* profile/cursor writes, and update only changed normalized rows on sync. */
|
|
125
|
+
private persist;
|
|
94
126
|
load(appId: string, profileId: string): Promise<ReadIndexState | undefined>;
|
|
95
|
-
|
|
127
|
+
loadVersioned(appId: string, profileId: string): Promise<{
|
|
128
|
+
state: ReadIndexState | undefined;
|
|
129
|
+
versions: Record<string, number>;
|
|
130
|
+
}>;
|
|
131
|
+
loadVersions(appId: string, profileId: string): Promise<ReadIndexVersions>;
|
|
132
|
+
invalidateTeam(appId: string, profileId: string, teamId: string, version: number, knownThreadIds?: readonly string[]): Promise<ReadIndexState>;
|
|
133
|
+
update(appId: string, profileId: string, transform: ReadIndexTransform): Promise<ReadIndexState>;
|
|
96
134
|
close(): Promise<void>;
|
|
97
135
|
}
|
|
98
136
|
export declare function sourcesInConversation(state: ReadIndexState, conversationId: string): IndexedReadThread[];
|
|
99
137
|
/** Aggregate logical history while each original source retains its own read cursor. */
|
|
100
|
-
export declare function unreadInConversation(state: ReadIndexState, conversationId: string): {
|
|
138
|
+
export declare function unreadInConversation(state: ReadIndexState, conversationId: string, hidden?: ReadonlyMap<string, ReadonlySet<string>>): {
|
|
101
139
|
unreadMessageCount: number;
|
|
102
140
|
unreadMentionCount: number;
|
|
103
141
|
badgeCount: number;
|