@pmate/chat-sdk 0.12.0-next.26 → 0.12.0-next.28
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/chatSession.d.ts +3 -0
- package/lib/runtime/chatSession.js +7 -2
- package/lib/runtime/conversationReading.d.ts +28 -0
- package/lib/runtime/conversationReading.js +43 -0
- package/lib/runtime/createChat.js +10 -0
- package/lib/runtime/index.d.ts +1 -0
- package/lib/runtime/index.js +1 -0
- package/lib/runtime/readIndex.js +15 -4
- package/lib/runtime/readingContent.d.ts +2 -0
- package/lib/runtime/readingContent.js +9 -0
- package/package.json +1 -1
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type ReadingCandidate, type MessageReadingState } from "./conversationReading.js";
|
|
1
2
|
import type { ConversationReadMessage } from "./conversationRead.js";
|
|
2
3
|
import { type ReadCountsResult } from "./readCounts.js";
|
|
3
4
|
import type { OnlineReachEvent } from "./reach.js";
|
|
@@ -16,6 +17,7 @@ export type ChatSessionOptions<T extends StoredMessage> = {
|
|
|
16
17
|
activeThreadId?: string;
|
|
17
18
|
reading: ReadingClientOptions;
|
|
18
19
|
decode: (message: ChatRuntimeMessage) => T | undefined;
|
|
20
|
+
readingCandidate?: (message: T) => ReadingCandidate | undefined;
|
|
19
21
|
onError: (error: unknown) => void;
|
|
20
22
|
};
|
|
21
23
|
export type ChatThreadSnapshot<T> = {
|
|
@@ -26,6 +28,7 @@ export type ChatThreadSnapshot<T> = {
|
|
|
26
28
|
complete: boolean;
|
|
27
29
|
readCounts?: ReadCountsResult;
|
|
28
30
|
unread: ReturnType<typeof unreadInConversation> | undefined;
|
|
31
|
+
messageReading: Readonly<Record<string, MessageReadingState>>;
|
|
29
32
|
};
|
|
30
33
|
export type ChatSummarySnapshot<T> = {
|
|
31
34
|
threads: readonly {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { projectConversationReading } from "./conversationReading.js";
|
|
1
2
|
import { ReadCountsClient } from "./readCounts.js";
|
|
2
3
|
import { WindowedTasks } from "./coalescer.js";
|
|
3
4
|
import { MessageReadersClient } from "./messageReaders.js";
|
|
@@ -27,7 +28,7 @@ export class ChatSession {
|
|
|
27
28
|
off = [];
|
|
28
29
|
globalListeners = new Set();
|
|
29
30
|
globalSnapshot;
|
|
30
|
-
closedThread = { messages: [], outgoing: [], hasEarlier: false, resetVersion: 0, complete: false, unread: undefined };
|
|
31
|
+
closedThread = { messages: [], outgoing: [], hasEarlier: false, resetVersion: 0, complete: false, unread: undefined, messageReading: {} };
|
|
31
32
|
closedSummary = { threads: [], unreadMessageCount: undefined, badgeCount: undefined, connection: "closed" };
|
|
32
33
|
connectionState = "closed";
|
|
33
34
|
catchUp;
|
|
@@ -430,7 +431,11 @@ export class ChatSession {
|
|
|
430
431
|
if (!snapshot) {
|
|
431
432
|
const messages = this.messages.getSnapshot(threadId);
|
|
432
433
|
const state = this.getReadingSnapshot();
|
|
433
|
-
|
|
434
|
+
const candidates = messages?.messages.flatMap(message => { const candidate = this.options.readingCandidate?.(message); return candidate ? [candidate] : []; }) ?? [];
|
|
435
|
+
const sources = state?.threads.filter(thread => (thread.conversationId ?? thread.id) === threadId) ?? [];
|
|
436
|
+
const kind = sources[0]?.kind ?? "dm";
|
|
437
|
+
const view = projectConversationReading(state, { id: threadId, kind }, candidates);
|
|
438
|
+
snapshot = { messages: messages?.messages ?? [], hasEarlier: messages?.hasEarlier ?? false, resetVersion: messages?.resetVersion ?? 0, complete: messages?.complete ?? false, outgoing: this.sender.snapshot(threadId), readCounts: this.countSnapshots.get(threadId), unread: view.unread, messageReading: view.messageReading };
|
|
434
439
|
this.snapshots.set(threadId, snapshot);
|
|
435
440
|
}
|
|
436
441
|
return snapshot;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { unreadInConversation, type ReadIndexState } from "./readIndex.js";
|
|
2
|
+
export type ReadingCandidate = {
|
|
3
|
+
id: string;
|
|
4
|
+
threadId: string;
|
|
5
|
+
from?: string;
|
|
6
|
+
mine?: boolean;
|
|
7
|
+
recalled?: boolean;
|
|
8
|
+
unread?: boolean;
|
|
9
|
+
attentionEligible?: boolean;
|
|
10
|
+
readPolicy?: "visible" | "explicit";
|
|
11
|
+
};
|
|
12
|
+
export type MessageReadingState = {
|
|
13
|
+
seq?: number;
|
|
14
|
+
read: boolean;
|
|
15
|
+
readPolicy: "visible" | "explicit";
|
|
16
|
+
};
|
|
17
|
+
export type ConversationReadingView = {
|
|
18
|
+
messageReading: Readonly<Record<string, MessageReadingState>>;
|
|
19
|
+
unread: ReturnType<typeof unreadInConversation> | undefined;
|
|
20
|
+
badgeCount: number | undefined;
|
|
21
|
+
};
|
|
22
|
+
/** One projection for indexed authority and observations awaiting indexing. A
|
|
23
|
+
* directory badge is optional compatibility metadata, never proof of coverage. */
|
|
24
|
+
export declare function projectConversationReading(state: ReadIndexState | undefined, conversation: {
|
|
25
|
+
id: string;
|
|
26
|
+
kind: "dm" | "group";
|
|
27
|
+
badgeCount?: number;
|
|
28
|
+
}, candidates?: readonly ReadingCandidate[]): ConversationReadingView;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { sourcesInConversation, unreadInConversation } from "./readIndex.js";
|
|
2
|
+
/** One projection for indexed authority and observations awaiting indexing. A
|
|
3
|
+
* directory badge is optional compatibility metadata, never proof of coverage. */
|
|
4
|
+
export function projectConversationReading(state, conversation, candidates = []) {
|
|
5
|
+
const sources = state ? sourcesInConversation(state, conversation.id) : [];
|
|
6
|
+
const sourceIds = new Set([conversation.id, ...sources.map(source => source.id)]);
|
|
7
|
+
const indexed = new Set(), messageReading = Object.create(null);
|
|
8
|
+
for (const source of sources)
|
|
9
|
+
for (const message of source.messages) {
|
|
10
|
+
indexed.add(message.id);
|
|
11
|
+
messageReading[message.id] = { seq: message.seq, readPolicy: message.readPolicy, read: message.recalled || message.from === state.profileId || !message.recipients.includes(state.profileId) || (message.readPolicy === "explicit" ? source.explicitReadMessageIds.includes(message.id) : message.seq <= source.readThroughSeq) };
|
|
12
|
+
}
|
|
13
|
+
const observed = new Map();
|
|
14
|
+
for (const intent of state?.observedReadIntents ?? [])
|
|
15
|
+
if (sourceIds.has(intent.threadId)) {
|
|
16
|
+
if (!observed.has(intent.messageId))
|
|
17
|
+
observed.set(intent.messageId, new Set());
|
|
18
|
+
observed.get(intent.messageId).add(intent.readPolicy);
|
|
19
|
+
}
|
|
20
|
+
const unique = new Map(candidates.filter(message => message.threadId === conversation.id).map(message => [message.id, message]));
|
|
21
|
+
let pendingMessages = 0, pendingMentions = 0, observedBadges = 0;
|
|
22
|
+
for (const message of unique.values()) {
|
|
23
|
+
const incoming = !message.mine && message.from !== state?.profileId && !message.recalled;
|
|
24
|
+
const attention = conversation.kind === "dm" || message.attentionEligible === true;
|
|
25
|
+
const policy = message.readPolicy ?? "visible";
|
|
26
|
+
const acknowledged = observed.get(message.id)?.has(policy) === true;
|
|
27
|
+
if (!indexed.has(message.id) && acknowledged)
|
|
28
|
+
messageReading[message.id] = { read: true, readPolicy: policy };
|
|
29
|
+
if (!incoming)
|
|
30
|
+
continue;
|
|
31
|
+
if (acknowledged && attention)
|
|
32
|
+
observedBadges++;
|
|
33
|
+
if (indexed.has(message.id) || acknowledged || message.unread !== true)
|
|
34
|
+
continue;
|
|
35
|
+
pendingMessages++;
|
|
36
|
+
if (message.attentionEligible === true)
|
|
37
|
+
pendingMentions++;
|
|
38
|
+
}
|
|
39
|
+
const authoritative = sources.length && state ? unreadInConversation(state, conversation.id) : undefined;
|
|
40
|
+
const unread = authoritative ? { unreadMessageCount: authoritative.unreadMessageCount + pendingMessages, unreadMentionCount: authoritative.unreadMentionCount + pendingMentions, badgeCount: authoritative.badgeCount + (conversation.kind === "group" ? pendingMentions : pendingMessages) } : undefined;
|
|
41
|
+
const pendingBadge = conversation.kind === "group" ? pendingMentions : pendingMessages;
|
|
42
|
+
return { messageReading, unread, badgeCount: unread?.badgeCount ?? (conversation.badgeCount === undefined ? undefined : Math.max(Math.max(0, conversation.badgeCount - observedBadges), pendingBadge)) };
|
|
43
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isReadingControl } from "./readingContent.js";
|
|
1
2
|
import { ChatSession } from "./chatSession.js";
|
|
2
3
|
import { SocialReadClient } from "./socialReadClient.js";
|
|
3
4
|
import { chatTeamId } from "./teamScope.js";
|
|
@@ -21,6 +22,15 @@ export function createChat(options) {
|
|
|
21
22
|
reading: { appId, profileId, sessionToken: token, hubHttpUrl: http, fetch: options.fetch, onError },
|
|
22
23
|
connection: { appId, profileId, teamId, hubUrl: ws, clientInstanceId: options.clientInstanceId, fetchImpl: options.fetch,
|
|
23
24
|
session: { token, identity: { accountId, app: appId, profileId } }, onNotification: options.onNotification, onReach: options.onReach },
|
|
25
|
+
readingCandidate: message => {
|
|
26
|
+
if (isReadingControl(message.body, appId))
|
|
27
|
+
return undefined;
|
|
28
|
+
const body = message.body;
|
|
29
|
+
if (!body || !["message", "quote"].includes(String(body.kind)) && !(body.kind === "contact" && ["friendRequest", "requestFriend"].includes(String(body.action))))
|
|
30
|
+
return undefined;
|
|
31
|
+
return { id: message.id, threadId: message.threadId, from: message.from, mine: message.from === profileId, recalled: message.recalled, unread: message.from !== profileId,
|
|
32
|
+
readPolicy: body?.contentKind === "voice" ? "explicit" : "visible", attentionEligible: Array.isArray(body.content?.mentions) && body.content.mentions.some(mention => mention?.id === profileId) };
|
|
33
|
+
},
|
|
24
34
|
decode: message => decodeIncoming(message, { appId, profileId, teamId }), onError,
|
|
25
35
|
});
|
|
26
36
|
}
|
package/lib/runtime/index.d.ts
CHANGED
|
@@ -30,3 +30,4 @@ export * from "./chatSession.js";
|
|
|
30
30
|
export * from "./messageReaders.js";
|
|
31
31
|
export * from "./createChat.js";
|
|
32
32
|
export * from "./conversationRead.js";
|
|
33
|
+
export { projectConversationReading, type ReadingCandidate, type MessageReadingState, type ConversationReadingView } from "./conversationReading.js";
|
package/lib/runtime/index.js
CHANGED
package/lib/runtime/readIndex.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Dexie } from "dexie";
|
|
2
|
+
import { isReadingControl } from "./readingContent.js";
|
|
2
3
|
import { isValidChatTeamId } from "./thread.js";
|
|
3
4
|
export class ReadSyncGap extends Error {
|
|
4
5
|
cursor;
|
|
@@ -158,7 +159,7 @@ export function unreadInThread(state, threadId) {
|
|
|
158
159
|
if (!thread)
|
|
159
160
|
throw new Error("UNKNOWN_READ_THREAD");
|
|
160
161
|
const explicit = new Set(thread.explicitReadMessageIds);
|
|
161
|
-
const messages = thread.messages.filter(message => !message.recalled && message.from !== state.profileId && message.recipients.includes(state.profileId)
|
|
162
|
+
const messages = thread.messages.filter(message => !message.recalled && !isReadingControl(message.payload, state.appId) && message.from !== state.profileId && message.recipients.includes(state.profileId)
|
|
162
163
|
&& (message.readPolicy === "explicit" ? !explicit.has(message.id) : message.seq > thread.readThroughSeq));
|
|
163
164
|
const unreadMessageCount = messages.length;
|
|
164
165
|
const unreadMentionCount = messages.filter(message => message.mentions.includes(state.profileId)).length;
|
|
@@ -278,7 +279,7 @@ export class IndexedDbReadIndex {
|
|
|
278
279
|
return this.hydrate(current, this.key(appId, profileId));
|
|
279
280
|
if (current) {
|
|
280
281
|
if (this.migrateLegacy)
|
|
281
|
-
await this.persist(appId, profileId, undefined, current);
|
|
282
|
+
await this.persist(appId, profileId, undefined, current, true);
|
|
282
283
|
return current;
|
|
283
284
|
}
|
|
284
285
|
if (!this.migrateLegacy || this.storageScope === undefined)
|
|
@@ -305,9 +306,11 @@ export class IndexedDbReadIndex {
|
|
|
305
306
|
}
|
|
306
307
|
/** Message bodies are immutable for local read observations: keep them out of
|
|
307
308
|
* profile/cursor writes, and update only changed normalized rows on sync. */
|
|
308
|
-
async persist(appId, profileId, previous, next) {
|
|
309
|
-
if (previous && !this.normalizedStates.has(previous))
|
|
309
|
+
async persist(appId, profileId, previous, next, replaceLegacy = false) {
|
|
310
|
+
if (previous && !this.normalizedStates.has(previous)) {
|
|
310
311
|
previous = undefined;
|
|
312
|
+
replaceLegacy = true;
|
|
313
|
+
}
|
|
311
314
|
const owner = JSON.stringify(this.key(appId, profileId));
|
|
312
315
|
const oldThreads = new Map(previous?.threads.map(thread => [thread.id, thread]) ?? []);
|
|
313
316
|
const nextIds = new Set(next.threads.map(thread => thread.id));
|
|
@@ -352,6 +355,14 @@ export class IndexedDbReadIndex {
|
|
|
352
355
|
await this.positionRows.delete([owner, id]);
|
|
353
356
|
await this.messageRows.where("[owner+thread]").equals([owner, id]).delete();
|
|
354
357
|
}
|
|
358
|
+
// A legacy document can replace the full profile after normalization. Its
|
|
359
|
+
// replacement is authoritative for the row set; discard unreachable bodies
|
|
360
|
+
// in the same transaction before installing the rebuilt normalized records.
|
|
361
|
+
if (replaceLegacy) {
|
|
362
|
+
await this.messageRows.where("owner").equals(owner).delete();
|
|
363
|
+
await this.threadRows.where("owner").equals(owner).delete();
|
|
364
|
+
await this.positionRows.where("owner").equals(owner).delete();
|
|
365
|
+
}
|
|
355
366
|
// Bound each IndexedDB bulk request. A snapshot and its cursor still commit
|
|
356
367
|
// atomically; bounding total snapshot transaction bytes is separate work.
|
|
357
368
|
for (let offset = 0; offset < positionWrites.length; offset += 128)
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** Social control envelopes do not contribute to message unread counts. */
|
|
2
|
+
export function isReadingControl(payload, appId) {
|
|
3
|
+
if (!payload || typeof payload !== "object")
|
|
4
|
+
return false;
|
|
5
|
+
const value = payload;
|
|
6
|
+
if (value.body !== undefined)
|
|
7
|
+
return isReadingControl(value.body, appId);
|
|
8
|
+
return value.kind === "message" && value.content?.appId === appId && (value.content.reaction !== undefined || value.content.recall !== undefined);
|
|
9
|
+
}
|
package/package.json
CHANGED