@pmate/chat-sdk 0.12.0-next.34 → 0.12.0-next.36

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.
@@ -297,7 +297,9 @@ export class ChatSession {
297
297
  }
298
298
  if (restart)
299
299
  continue;
300
- return { messages: pages.reverse().flat(), hasEarlier: false, complete: true, resetVersion: version };
300
+ const result = { messages: pages.reverse().flat(), hasEarlier: false, complete: true, resetVersion: version };
301
+ await this.messages.expandWindow(threadId, result);
302
+ return result;
301
303
  }
302
304
  catch (error) {
303
305
  this.assertOpen();
@@ -453,9 +455,9 @@ export class ChatSession {
453
455
  const messages = previous && previous.messages.length === raw.length && previous.messages.every((message, index) => message === raw[index]) ? previous.messages : raw;
454
456
  const outgoing = this.sender.snapshot(threadId);
455
457
  const sameSource = previous && previous.messages === messages && previous.outgoing.length === outgoing.length && previous.outgoing.every((record, index) => record === outgoing[index]);
456
- const semanticReading = previous?.messageReading ?? {};
457
- const semantics = sameSource ? previous.timeline : projectMessageTimeline({ appId: this.options.messages.scope.appId, profileId: this.options.messages.scope.profileId, threadId, messages, outgoing, decode: this.decodeTimeline, reading: semanticReading, previous: previous?.timeline });
458
458
  const candidates = new Map(messages.flatMap(message => { const candidate = this.options.readingCandidate?.(message); return candidate ? [[candidate.id, candidate]] : []; }));
459
+ const semanticReading = previous?.messageReading ?? {};
460
+ const semantics = sameSource ? previous.timeline : projectMessageTimeline({ appId: this.options.messages.scope.appId, profileId: this.options.messages.scope.profileId, threadId, messages, outgoing, candidates, decode: this.decodeTimeline, reading: semanticReading, previous: previous?.timeline });
459
461
  for (const item of semantics)
460
462
  if (item.recalled)
461
463
  candidates.set(item.id, { ...candidates.get(item.id), id: item.id, threadId, from: item.from, mine: item.mine, recalled: true });
@@ -464,7 +466,7 @@ export class ChatSession {
464
466
  const view = projectConversationReading(state, { id: threadId, kind }, [...candidates.values()]);
465
467
  const unread = previous && JSON.stringify(previous.unread) === JSON.stringify(view.unread) ? previous.unread : view.unread;
466
468
  const messageReading = previous && JSON.stringify(previous.messageReading) === JSON.stringify(view.messageReading) ? previous.messageReading : view.messageReading;
467
- const timeline = messageReading === semanticReading ? semantics : projectMessageTimeline({ appId: this.options.messages.scope.appId, profileId: this.options.messages.scope.profileId, threadId, messages, outgoing, decode: this.decodeTimeline, reading: messageReading, previous: previous?.timeline });
469
+ const timeline = messageReading === semanticReading ? semantics : projectMessageTimeline({ appId: this.options.messages.scope.appId, profileId: this.options.messages.scope.profileId, threadId, messages, outgoing, candidates, decode: this.decodeTimeline, reading: messageReading, previous: previous?.timeline });
468
470
  return { timeline, messages, hasEarlier: source?.hasEarlier ?? false, resetVersion: source?.resetVersion ?? 0, complete: source?.complete ?? false, outgoing, readCounts: this.countSnapshots.get(threadId), unread, messageReading };
469
471
  }
470
472
  subscribe(target, listener, fields) {
@@ -91,6 +91,8 @@ export declare class MessageClient<T extends StoredMessage> {
91
91
  private flushWrites;
92
92
  private runWrites;
93
93
  synchronize(thread: string): Promise<void>;
94
+ /** Keep an explicitly requested history range in the shared subscribed view. */
95
+ expandWindow(thread: string, page: MessageSnapshot<T>): Promise<void>;
94
96
  earlier(thread: string, before: {
95
97
  timestamp: number;
96
98
  id: string;
@@ -485,6 +485,18 @@ export class MessageClient {
485
485
  this.syncing.set(thread, task);
486
486
  return task;
487
487
  }
488
+ /** Keep an explicitly requested history range in the shared subscribed view. */
489
+ async expandWindow(thread, page) {
490
+ const first = page.messages[0];
491
+ if (!first || !page.complete)
492
+ return;
493
+ const old = this.windowEdges.get(thread);
494
+ if (!old || old.version !== page.resetVersion || first.timestamp < old.timestamp || first.timestamp === old.timestamp && first.id < old.id)
495
+ this.windowEdges.set(thread, { timestamp: first.timestamp, id: first.id, version: page.resetVersion });
496
+ const snapshot = await this.reload(thread);
497
+ if (snapshot.resetVersion !== page.resetVersion)
498
+ throw new Error("MESSAGE_PROJECTION_CHANGED");
499
+ }
488
500
  async earlier(thread, before, limit = 100, expand = false) {
489
501
  await this.ensureScope();
490
502
  if (this.scopeDenied)
@@ -500,12 +512,8 @@ export class MessageClient {
500
512
  await this.synchronize(thread);
501
513
  }
502
514
  const page = await this.fillWindow(thread, limit, before);
503
- if (expand && page.complete && page.messages.length) {
504
- const first = page.messages[0], old = this.windowEdges.get(thread);
505
- if (!old || old.version !== page.resetVersion || first.timestamp < old.timestamp || first.timestamp === old.timestamp && first.id < old.id)
506
- this.windowEdges.set(thread, { timestamp: first.timestamp, id: first.id, version: page.resetVersion });
507
- await this.reload(thread);
508
- }
515
+ if (expand)
516
+ await this.expandWindow(thread, page);
509
517
  return page;
510
518
  }
511
519
  catch (error) {
@@ -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
+ }
@@ -33,12 +33,14 @@ export declare const messageScopeKey: (scope: MessageScope) => string;
33
33
  /** Normalized browser cache. Data and coverage advance together in each commit.
34
34
  * Merge runs inside the write transaction against the latest cross-tab state. */
35
35
  export declare class IndexedDbMessageStore<T extends StoredMessage> {
36
+ private scope;
36
37
  private merge;
37
38
  private db;
38
39
  private rows;
39
40
  private legacy;
40
41
  private coverage;
41
42
  private accessRows;
43
+ private controls;
42
44
  private key;
43
45
  constructor(scope: MessageScope, merge: (previous: T | undefined, incoming: T) => T, options?: {
44
46
  indexedDB?: IDBFactory;
@@ -69,6 +71,9 @@ export declare class IndexedDbMessageStore<T extends StoredMessage> {
69
71
  authoritative?: boolean;
70
72
  scopeVersion?: number;
71
73
  }): Promise<number>;
74
+ private rememberControls;
75
+ private controlPage;
76
+ private finishPage;
72
77
  windows(): Promise<CoverageRow[]>;
73
78
  scopeAccess(): Promise<MessageScopeAccess>;
74
79
  private scopedCoverage;
@@ -1,22 +1,28 @@
1
1
  import { Dexie } from "dexie";
2
+ import { messageControls, controlKey, newerControl, applyMessageControls } from "./messageControls.js";
2
3
  export const messageScopeKey = (scope) => JSON.stringify([scope.appId, scope.accountId, scope.profileId, scope.teamId]);
3
4
  /** Normalized browser cache. Data and coverage advance together in each commit.
4
5
  * Merge runs inside the write transaction against the latest cross-tab state. */
5
6
  export class IndexedDbMessageStore {
7
+ scope;
6
8
  merge;
7
9
  db;
8
10
  rows;
9
11
  legacy;
10
12
  coverage;
11
13
  accessRows;
14
+ controls;
12
15
  key;
13
16
  constructor(scope, merge, options = {}) {
17
+ this.scope = scope;
14
18
  this.merge = merge;
15
19
  this.key = messageScopeKey(scope);
16
20
  this.db = new Dexie(options.name ?? "pmate-chat-messages", { indexedDB: options.indexedDB ?? globalThis.indexedDB, IDBKeyRange: globalThis.IDBKeyRange });
17
21
  this.db.version(1).stores({ messages: "[scope+thread+id],[scope+thread+time+id],[scope+thread],scope", coverage: "[scope+thread],scope" });
18
22
  this.db.version(2).stores({ legacyMessages: "[scope+thread+id],[scope+thread+time+id],[scope+thread],scope" });
19
23
  this.db.version(3).stores({ scopeAccess: "scope" });
24
+ this.db.version(4).stores({ messageControls: "[scope+thread+target+kind+actor+emoji],[scope+thread+target],[scope+thread],scope" });
25
+ this.controls = this.db.table("messageControls");
20
26
  this.accessRows = this.db.table("scopeAccess");
21
27
  this.legacy = this.db.table("legacyMessages");
22
28
  this.rows = this.db.table("messages");
@@ -27,14 +33,15 @@ export class IndexedDbMessageStore {
27
33
  async read(thread, limit = 100, before) {
28
34
  if (!Number.isSafeInteger(limit) || limit < 1 || limit > 1000)
29
35
  throw new Error("INVALID_MESSAGE_LIMIT");
30
- return this.db.transaction("r", this.rows, this.coverage, this.accessRows, async () => {
36
+ const page = await this.db.transaction("r", this.rows, this.coverage, this.accessRows, this.controls, async () => {
31
37
  const access = await this.scopeAccess();
32
38
  if (access.revoked)
33
39
  return { messages: [], hasEarlier: false, coverage: { complete: false, revoked: true, resetVersion: 0, scopeVersion: access.version } };
34
40
  const range = this.rows.where("[scope+thread+time+id]").between([this.key, thread, Dexie.minKey, Dexie.minKey], [this.key, thread, before?.timestamp ?? Dexie.maxKey, before?.id ?? Dexie.maxKey], true, !before);
35
41
  const records = await range.reverse().limit(limit + 1).toArray();
36
- return { messages: records.slice(0, limit).reverse().map(row => row.value), hasEarlier: records.length > limit, coverage: this.scopedCoverage(await this.coverage.get([this.key, thread]), access) };
42
+ return this.controlPage({ messages: records.slice(0, limit).reverse().map(row => row.value), hasEarlier: records.length > limit, coverage: this.scopedCoverage(await this.coverage.get([this.key, thread]), access) });
37
43
  });
44
+ return this.finishPage(thread, page);
38
45
  }
39
46
  /** Migration records never claim coverage or overwrite authoritative rows. */
40
47
  async importLegacy(thread, messages) {
@@ -48,13 +55,19 @@ export class IndexedDbMessageStore {
48
55
  if (bytes > 1_048_576)
49
56
  throw new Error("MESSAGE_BATCH_TOO_LARGE");
50
57
  }
51
- return this.db.transaction("rw", this.rows, this.legacy, this.coverage, this.accessRows, async () => {
58
+ return this.db.transaction("rw", [this.rows, this.legacy, this.coverage, this.accessRows, this.controls], async () => {
52
59
  const access = await this.scopeAccess();
53
60
  const coverage = await this.coverage.get([this.key, thread]);
54
61
  if (access.revoked || coverage?.revoked)
55
62
  throw new Error("MESSAGE_ACCESS_REVOKED");
56
63
  if (access.version > 0)
57
64
  throw new Error("MESSAGE_PROJECTION_CHANGED");
65
+ const proofs = messages.map(message => messageControls(message, this.scope, this.key));
66
+ const controlsOnly = messages.length > 0 && proofs.every((events, index) => events.length > 0 && !messages[index].recalled && !messages[index].legacyDisplay?.recalled);
67
+ if (controlsOnly) {
68
+ await this.rememberControls(proofs.flat());
69
+ return 0;
70
+ }
58
71
  if ((coverage?.resetVersion ?? 0) > 0)
59
72
  throw new Error("MESSAGE_PROJECTION_CHANGED");
60
73
  const ids = [...new Set(messages.map(message => message.id))], keys = ids.map(id => [this.key, thread, id]);
@@ -71,6 +84,7 @@ export class IndexedDbMessageStore {
71
84
  if (JSON.stringify(old) !== JSON.stringify(value))
72
85
  changed.set(message.id, { scope: this.key, thread, id: value.id, time: value.timestamp, value });
73
86
  }
87
+ await this.rememberControls([...changed.values()].flatMap(row => messageControls(row.value, this.scope, this.key)));
74
88
  if (changed.size)
75
89
  await this.legacy.bulkPut([...changed.values()]);
76
90
  return changed.size;
@@ -80,7 +94,7 @@ export class IndexedDbMessageStore {
80
94
  async readFallback(thread, limit = 100, before) {
81
95
  if (!Number.isSafeInteger(limit) || limit < 1 || limit > 1000)
82
96
  throw new Error("INVALID_MESSAGE_LIMIT");
83
- return this.db.transaction("r", this.rows, this.legacy, this.coverage, this.accessRows, async () => {
97
+ const page = await this.db.transaction("r", [this.rows, this.legacy, this.coverage, this.accessRows, this.controls], async () => {
84
98
  const access = await this.scopeAccess();
85
99
  const coverage = access.revoked ? { complete: false, revoked: true, resetVersion: 0, scopeVersion: access.version } : this.scopedCoverage(await this.coverage.get([this.key, thread]), access);
86
100
  if (coverage?.revoked)
@@ -88,8 +102,9 @@ export class IndexedDbMessageStore {
88
102
  const read = (table) => table.where("[scope+thread+time+id]").between([this.key, thread, Dexie.minKey, Dexie.minKey], [this.key, thread, before?.timestamp ?? Dexie.maxKey, before?.id ?? Dexie.maxKey], true, !before).reverse().limit(limit + 1).toArray();
89
103
  const [canonical, legacy] = await Promise.all([read(this.rows), read(this.legacy)]);
90
104
  const rows = [...new Map([...legacy, ...canonical].map(row => [row.id, row])).values()].sort((a, b) => a.time - b.time || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
91
- return { messages: rows.slice(-limit).map(row => row.value), hasEarlier: rows.length > limit, coverage };
105
+ return this.controlPage({ messages: rows.slice(-limit).map(row => row.value), hasEarlier: rows.length > limit, coverage });
92
106
  });
107
+ return this.finishPage(thread, page);
93
108
  }
94
109
  async commit(thread, messages, coverage, guard) {
95
110
  for (const message of messages)
@@ -97,7 +112,7 @@ export class IndexedDbMessageStore {
97
112
  throw new Error("MESSAGE_SCOPE_MISMATCH");
98
113
  if (messages.length > 1000)
99
114
  throw new Error("MESSAGE_BATCH_TOO_LARGE");
100
- return this.db.transaction("rw", this.rows, this.legacy, this.coverage, this.accessRows, async () => {
115
+ return this.db.transaction("rw", [this.rows, this.legacy, this.coverage, this.accessRows, this.controls], async () => {
101
116
  const access = await this.scopeAccess();
102
117
  if ((guard?.scopeVersion ?? 0) !== access.version)
103
118
  throw new Error("MESSAGE_SCOPE_CHANGED");
@@ -126,6 +141,7 @@ export class IndexedDbMessageStore {
126
141
  if (!canonicalIds.has(message.id) || JSON.stringify(old) !== JSON.stringify(value))
127
142
  changed.set(message.id, { scope: this.key, thread, id: value.id, time: value.timestamp, value });
128
143
  }
144
+ await this.rememberControls(messages.flatMap(message => messageControls(message, this.scope, this.key)));
129
145
  if (changed.size)
130
146
  await this.rows.bulkPut([...changed.values()]);
131
147
  const replaced = legacy.flatMap(row => row ? [[this.key, thread, row.id]] : []);
@@ -139,6 +155,47 @@ export class IndexedDbMessageStore {
139
155
  return changed.size;
140
156
  });
141
157
  }
158
+ async rememberControls(events) {
159
+ const latest = new Map();
160
+ for (const event of events) {
161
+ const key = JSON.stringify(controlKey(event));
162
+ latest.set(key, newerControl(latest.get(key), event));
163
+ }
164
+ const values = [...latest.values()];
165
+ for (let offset = 0; offset < values.length; offset += 128) {
166
+ const batch = values.slice(offset, offset + 128), previous = await this.controls.bulkGet(batch.map(controlKey));
167
+ const changed = batch.filter((event, index) => newerControl(previous[index], event) !== previous[index]);
168
+ if (changed.length)
169
+ await this.controls.bulkPut(changed);
170
+ }
171
+ }
172
+ async controlPage(page) {
173
+ const events = page.messages.flatMap(message => messageControls(message, this.scope, this.key));
174
+ const old = events.length ? await this.controls.bulkGet(events.map(controlKey)) : [];
175
+ const pendingControls = events.filter((event, index) => newerControl(old[index], event) !== old[index]);
176
+ const facts = await this.controls.where("[scope+thread+target]").anyOf(page.messages.map(message => [this.key, message.threadId, message.id])).toArray();
177
+ const latest = new Map();
178
+ for (const event of [...facts, ...events]) {
179
+ const key = JSON.stringify(controlKey(event));
180
+ latest.set(key, newerControl(latest.get(key), event));
181
+ }
182
+ return { ...page, messages: applyMessageControls(page.messages, [...latest.values()]), pendingControls };
183
+ }
184
+ async finishPage(thread, page) {
185
+ const { pendingControls, ...result } = page;
186
+ // v3 caches migrate only source proofs from already-read pages, with no body
187
+ // rewrite. Steady reads remain a single read-only IndexedDB transaction.
188
+ if (pendingControls?.length)
189
+ await this.db.transaction("rw", this.controls, this.coverage, this.accessRows, async () => {
190
+ const access = await this.scopeAccess(), current = await this.coverage.get([this.key, thread]);
191
+ if (access.revoked || access.version !== (page.coverage?.scopeVersion ?? 0))
192
+ throw new Error("MESSAGE_SCOPE_CHANGED");
193
+ if ((current?.resetVersion ?? 0) !== (page.coverage?.resetVersion ?? 0))
194
+ throw new Error("MESSAGE_PROJECTION_CHANGED");
195
+ await this.rememberControls(pendingControls);
196
+ });
197
+ return result;
198
+ }
142
199
  async windows() {
143
200
  return this.db.transaction("r", this.coverage, this.accessRows, async () => {
144
201
  if ((await this.scopeAccess()).revoked)
@@ -156,6 +213,7 @@ export class IndexedDbMessageStore {
156
213
  return access.version ? { ...coverage, complete: coverage?.complete ?? false, scopeVersion: access.version } : coverage;
157
214
  }
158
215
  async clearScopeRows() {
216
+ await this.controls.where("scope").equals(this.key).delete();
159
217
  await this.rows.where("[scope+thread+id]").between([this.key, Dexie.minKey, Dexie.minKey], [this.key, Dexie.maxKey, Dexie.maxKey], true, true).delete();
160
218
  await this.legacy.where("[scope+thread+id]").between([this.key, Dexie.minKey, Dexie.minKey], [this.key, Dexie.maxKey, Dexie.maxKey], true, true).delete();
161
219
  await this.coverage.where("[scope+thread]").between([this.key, Dexie.minKey], [this.key, Dexie.maxKey], true, true).delete();
@@ -170,7 +228,7 @@ export class IndexedDbMessageStore {
170
228
  await this.accessRows.put(next);
171
229
  return next;
172
230
  });
173
- await this.db.transaction("rw", this.rows, this.legacy, this.coverage, this.accessRows, async () => {
231
+ await this.db.transaction("rw", [this.rows, this.legacy, this.coverage, this.accessRows, this.controls], async () => {
174
232
  const current = await this.scopeAccess();
175
233
  if (current.version === access.version && current.revoked)
176
234
  await this.clearScopeRows();
@@ -179,7 +237,7 @@ export class IndexedDbMessageStore {
179
237
  }
180
238
  /** A durable epoch prevents another tab's pre-reset request from restoring rows. */
181
239
  async resetThread(thread, revoked = false, scopeVersion = 0) {
182
- return this.db.transaction("rw", this.rows, this.legacy, this.coverage, this.accessRows, async () => {
240
+ return this.db.transaction("rw", [this.rows, this.legacy, this.coverage, this.accessRows, this.controls], async () => {
183
241
  const access = await this.scopeAccess();
184
242
  if (access.version !== scopeVersion)
185
243
  throw new Error("MESSAGE_SCOPE_CHANGED");
@@ -187,6 +245,8 @@ export class IndexedDbMessageStore {
187
245
  throw new Error("MESSAGE_ACCESS_REVOKED");
188
246
  const old = await this.coverage.get([this.key, thread]);
189
247
  const resetVersion = (old?.resetVersion ?? 0) + 1;
248
+ if (revoked)
249
+ await this.controls.where("[scope+thread]").equals([this.key, thread]).delete();
190
250
  await this.rows.where("[scope+thread]").equals([this.key, thread]).delete();
191
251
  await this.legacy.where("[scope+thread]").equals([this.key, thread]).delete();
192
252
  await this.coverage.put({ scope: this.key, thread, complete: false, resetVersion, revoked });
@@ -194,14 +254,16 @@ export class IndexedDbMessageStore {
194
254
  });
195
255
  }
196
256
  async removeThread(thread) {
197
- await this.db.transaction("rw", this.rows, this.legacy, this.coverage, this.accessRows, async () => {
257
+ await this.db.transaction("rw", [this.rows, this.legacy, this.coverage, this.accessRows, this.controls], async () => {
258
+ await this.controls.where("[scope+thread]").equals([this.key, thread]).delete();
198
259
  await this.rows.where("[scope+thread]").equals([this.key, thread]).delete();
199
260
  await this.legacy.where("[scope+thread]").equals([this.key, thread]).delete();
200
261
  await this.coverage.delete([this.key, thread]);
201
262
  });
202
263
  }
203
264
  async clear() {
204
- await this.db.transaction("rw", this.rows, this.legacy, this.coverage, this.accessRows, async () => {
265
+ await this.db.transaction("rw", [this.rows, this.legacy, this.coverage, this.accessRows, this.controls], async () => {
266
+ await this.controls.where("scope").equals(this.key).delete();
205
267
  await this.legacy.where("scope").equals(this.key).delete();
206
268
  await this.rows.where("scope").equals(this.key).delete();
207
269
  await this.coverage.where("scope").equals(this.key).delete();
@@ -1,7 +1,7 @@
1
1
  import type { StoredMessage } from "./messageStore.js";
2
2
  import type { OutgoingMessage } from "./durableSender.js";
3
3
  import type { ChatRuntimeMessage } from "./protocol.js";
4
- import type { MessageReadingState } from "./conversationReading.js";
4
+ import type { MessageReadingState, ReadingCandidate } from "./conversationReading.js";
5
5
  export type TimelineReactionEvent = Reaction & {
6
6
  threadId: string;
7
7
  };
@@ -18,6 +18,8 @@ export type TimelineItem<T> = {
18
18
  mine: boolean;
19
19
  status: "sending" | "sent" | "failed" | "read";
20
20
  recalled: boolean;
21
+ unread?: boolean;
22
+ attentionEligible?: boolean;
21
23
  message: T | undefined;
22
24
  reading?: MessageReadingState;
23
25
  reactions: readonly TimelineReaction[];
@@ -42,6 +44,7 @@ export declare function projectMessageTimeline<T extends StoredMessage>(input: {
42
44
  outgoing: readonly OutgoingMessage[];
43
45
  decode: (message: ChatRuntimeMessage) => T | undefined;
44
46
  reading: Readonly<Record<string, MessageReadingState>>;
47
+ candidates?: ReadonlyMap<string, ReadingCandidate>;
45
48
  previous?: readonly TimelineItem<T>[];
46
49
  }): readonly TimelineItem<T>[];
47
50
  export {};
@@ -106,13 +106,14 @@ export function projectMessageTimeline(input) {
106
106
  continue;
107
107
  const mine = envelope.from === input.profileId, reading = input.reading[message.id];
108
108
  const withdrawn = Boolean(tombstones.has(message.id) || message.recalled || envelope.legacyDisplay?.recalled || envelope.from && recalled.get(message.id)?.has(envelope.from));
109
- 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,
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 } : {}),
110
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) }; }) };
111
112
  const old = previous.get(item.id);
112
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;
113
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]); });
114
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; });
115
- 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 && sameReading && sameReactions ? old : item);
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);
116
117
  }
117
118
  result.sort((a, b) => a.timestamp - b.timestamp || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
118
119
  return input.previous?.length === result.length && result.every((item, index) => item === input.previous[index]) ? input.previous : result;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pmate/chat-sdk",
3
- "version": "0.12.0-next.34",
3
+ "version": "0.12.0-next.36",
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",