@openmarket/rooms-client 0.1.0
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/LICENSE +201 -0
- package/README.md +25 -0
- package/dist/agent/armed-mode.d.ts +187 -0
- package/dist/agent/armed-mode.js +306 -0
- package/dist/agent/clients/common.d.ts +70 -0
- package/dist/agent/clients/common.js +46 -0
- package/dist/agent/lane-draft.d.ts +15 -0
- package/dist/agent/lane-draft.js +43 -0
- package/dist/agent/lane-prompts.d.ts +102 -0
- package/dist/agent/lane-prompts.js +120 -0
- package/dist/agent/library-context.d.ts +38 -0
- package/dist/agent/library-context.js +57 -0
- package/dist/agent/library-model.d.ts +41 -0
- package/dist/agent/library-model.js +173 -0
- package/dist/agent/room-context.d.ts +151 -0
- package/dist/agent/room-context.js +251 -0
- package/dist/agent/sidebar-model.d.ts +86 -0
- package/dist/agent/sidebar-model.js +162 -0
- package/dist/agent/topic-inbox.d.ts +119 -0
- package/dist/agent/topic-inbox.js +266 -0
- package/dist/agent/topic-view-batcher.d.ts +54 -0
- package/dist/agent/topic-view-batcher.js +115 -0
- package/dist/client.d.ts +421 -0
- package/dist/client.js +1428 -0
- package/dist/doc-reconcile.d.ts +100 -0
- package/dist/doc-reconcile.js +110 -0
- package/dist/doc-uri.d.ts +29 -0
- package/dist/doc-uri.js +55 -0
- package/dist/endpoints.d.ts +9 -0
- package/dist/endpoints.js +13 -0
- package/dist/jwt.d.ts +10 -0
- package/dist/jwt.js +51 -0
- package/dist/library-publisher.d.ts +52 -0
- package/dist/library-publisher.js +49 -0
- package/dist/merge3.d.ts +50 -0
- package/dist/merge3.js +280 -0
- package/dist/names.d.ts +6 -0
- package/dist/names.js +35 -0
- package/dist/shared/emoji-data.d.ts +22 -0
- package/dist/shared/emoji-data.js +8834 -0
- package/dist/shared/mentions.d.ts +6 -0
- package/dist/shared/mentions.js +32 -0
- package/dist/shared/rooms-protocol.d.ts +1183 -0
- package/dist/shared/rooms-protocol.js +160 -0
- package/dist/social-client.d.ts +361 -0
- package/dist/social-client.js +686 -0
- package/dist/social-types.d.ts +338 -0
- package/dist/social-types.js +1 -0
- package/dist/suggestion-attribution.d.ts +10 -0
- package/dist/suggestion-attribution.js +56 -0
- package/dist/topic-uri.d.ts +26 -0
- package/dist/topic-uri.js +40 -0
- package/dist/types.d.ts +2 -0
- package/dist/types.js +1 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.js +2 -0
- package/dist/ws-client.d.ts +115 -0
- package/dist/ws-client.js +491 -0
- package/package.json +180 -0
- package/src/agent/armed-mode.ts +368 -0
- package/src/agent/clients/common.ts +91 -0
- package/src/agent/lane-draft.ts +47 -0
- package/src/agent/lane-prompts.ts +267 -0
- package/src/agent/library-context.ts +97 -0
- package/src/agent/library-model.ts +210 -0
- package/src/agent/room-context.ts +351 -0
- package/src/agent/sidebar-model.ts +235 -0
- package/src/agent/topic-inbox.ts +297 -0
- package/src/agent/topic-view-batcher.ts +134 -0
- package/src/client.ts +2331 -0
- package/src/doc-reconcile.ts +160 -0
- package/src/doc-uri.ts +83 -0
- package/src/endpoints.ts +14 -0
- package/src/jwt.ts +59 -0
- package/src/library-publisher.ts +93 -0
- package/src/merge3.ts +326 -0
- package/src/names.ts +44 -0
- package/src/shared/emoji-data.ts +8868 -0
- package/src/shared/mentions.ts +32 -0
- package/src/shared/rooms-protocol.ts +1339 -0
- package/src/social-client.ts +1287 -0
- package/src/social-types.ts +376 -0
- package/src/suggestion-attribution.ts +83 -0
- package/src/topic-uri.ts +64 -0
- package/src/types.ts +83 -0
- package/src/version.ts +2 -0
- package/src/ws-client.ts +611 -0
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The open-core topic model: one room's registry rows plus the viewer's
|
|
3
|
+
* per-bucket state (cursor, attention, unread), with the unread math,
|
|
4
|
+
* muted exclusions, merge roll-up, and the deterministic catch-up order
|
|
5
|
+
* that every surface (GUI store, TUI, inbox, keyboard nav) consumes
|
|
6
|
+
* instead of reimplementing semantics.
|
|
7
|
+
*
|
|
8
|
+
* A BUCKET is a topicId, or the reserved '' key (UNTOPICKED_BUCKET) for
|
|
9
|
+
* the room's untopicked linear messages, matching the durable store's
|
|
10
|
+
* reserved empty-string cursor row. Per the phase 2 contract the buckets
|
|
11
|
+
* are independent: reading the linear lane never marks a topic seen, and
|
|
12
|
+
* vice versa.
|
|
13
|
+
*
|
|
14
|
+
* This model is a pure state machine: no timers, no sockets, no writes.
|
|
15
|
+
* Cursor ACKs happen exclusively through the view batcher; the model only
|
|
16
|
+
* mirrors positions it is told about.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import type {
|
|
20
|
+
RoomTopic,
|
|
21
|
+
RoomTopicAttention,
|
|
22
|
+
RoomTopicBucketState,
|
|
23
|
+
RoomTopicsResultPayload,
|
|
24
|
+
} from "../shared/rooms-protocol.js";
|
|
25
|
+
|
|
26
|
+
/** The reserved bucket key for untopicked (linear) messages. */
|
|
27
|
+
export const UNTOPICKED_BUCKET = "";
|
|
28
|
+
|
|
29
|
+
export interface TopicInboxItem {
|
|
30
|
+
/** Bucket key: a topicId, or UNTOPICKED_BUCKET for the linear lane. */
|
|
31
|
+
key: string;
|
|
32
|
+
/** The registry row; undefined for the untopicked bucket. */
|
|
33
|
+
topic: RoomTopic | undefined;
|
|
34
|
+
state: RoomTopicBucketState;
|
|
35
|
+
/** Unread rolled up from merged-away alias buckets (survivors only). */
|
|
36
|
+
aliasUnread: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface TopicInboxEntry {
|
|
40
|
+
seq: number;
|
|
41
|
+
userId: string;
|
|
42
|
+
topicId?: string | undefined;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function emptyBucket(): RoomTopicBucketState {
|
|
46
|
+
return { lastSeq: 0, unreadCount: 0, cursor: 0, attention: "default" };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export class RoomTopicInbox {
|
|
50
|
+
readonly room: string;
|
|
51
|
+
/** True once a withState hydration has landed; counts are trustworthy. */
|
|
52
|
+
hydrated = false;
|
|
53
|
+
headSeq = 0;
|
|
54
|
+
private readonly topics = new Map<string, RoomTopic>();
|
|
55
|
+
private readonly buckets = new Map<string, RoomTopicBucketState>();
|
|
56
|
+
|
|
57
|
+
constructor(room: string) {
|
|
58
|
+
this.room = room;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Replace registry rows and per-bucket state from a withState TOPICS
|
|
62
|
+
* reply. Idempotent; later live frames layer on top.
|
|
63
|
+
*
|
|
64
|
+
* Positions only move FORWARD: the server read can lag local truth by
|
|
65
|
+
* the ack debounce plus the relay's cursor write buffer, so a rehydrate
|
|
66
|
+
* (TOPIC_UPDATED / ENTRY_UPDATED are hot triggers) must never resurrect
|
|
67
|
+
* a just-cleared badge. Cursors and lastSeq merge as max(local, server);
|
|
68
|
+
* a bucket whose merged cursor reaches its lastSeq is read, whatever
|
|
69
|
+
* count the stale server row carried. */
|
|
70
|
+
hydrate(payload: Pick<RoomTopicsResultPayload, "topics" | "state" | "headSeq">): void {
|
|
71
|
+
this.topics.clear();
|
|
72
|
+
for (const topic of payload.topics) this.topics.set(topic.topicId, topic);
|
|
73
|
+
const previous = new Map(this.buckets);
|
|
74
|
+
this.buckets.clear();
|
|
75
|
+
for (const [key, state] of Object.entries(payload.state ?? {})) {
|
|
76
|
+
const local = previous.get(key);
|
|
77
|
+
const cursor = Math.max(state.cursor, local?.cursor ?? 0);
|
|
78
|
+
const lastSeq = Math.max(state.lastSeq, local?.lastSeq ?? 0);
|
|
79
|
+
this.buckets.set(key, {
|
|
80
|
+
...state,
|
|
81
|
+
cursor,
|
|
82
|
+
lastSeq,
|
|
83
|
+
unreadCount: cursor >= lastSeq ? 0 : state.unreadCount,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
if (!this.buckets.has(UNTOPICKED_BUCKET)) {
|
|
87
|
+
this.buckets.set(UNTOPICKED_BUCKET, emptyBucket());
|
|
88
|
+
}
|
|
89
|
+
this.headSeq = Math.max(this.headSeq, payload.headSeq ?? 0);
|
|
90
|
+
this.hydrated = true;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
topicFor(topicId: string): RoomTopic | undefined {
|
|
94
|
+
return this.topics.get(topicId);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Follow a merged husk to its survivor (chains are flattened server-side
|
|
98
|
+
* to one hop). A live id, unknown id, or the untopicked key returns
|
|
99
|
+
* itself. */
|
|
100
|
+
survivorOf(key: string): string {
|
|
101
|
+
if (key === UNTOPICKED_BUCKET) return key;
|
|
102
|
+
const merged = this.topics.get(key)?.mergedInto;
|
|
103
|
+
return merged || key;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Merged-away buckets whose survivor is the given topic. */
|
|
107
|
+
aliasesOf(topicId: string): string[] {
|
|
108
|
+
const aliases: string[] = [];
|
|
109
|
+
for (const topic of this.topics.values()) {
|
|
110
|
+
if (topic.mergedInto === topicId) aliases.push(topic.topicId);
|
|
111
|
+
}
|
|
112
|
+
return aliases;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Registry rows in listed (creation) order, husks excluded. */
|
|
116
|
+
openTopics(options?: { includeResolved?: boolean }): RoomTopic[] {
|
|
117
|
+
const rows: RoomTopic[] = [];
|
|
118
|
+
for (const topic of this.topics.values()) {
|
|
119
|
+
if (topic.mergedInto) continue;
|
|
120
|
+
if (!options?.includeResolved && topic.resolved) continue;
|
|
121
|
+
rows.push(topic);
|
|
122
|
+
}
|
|
123
|
+
return rows;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
bucketFor(key: string): RoomTopicBucketState {
|
|
127
|
+
return this.buckets.get(key) ?? emptyBucket();
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Every bucket still carrying unread, muted and merged husks included:
|
|
131
|
+
* the explicit mark-channel-read verb clears ALL of them (a muted or
|
|
132
|
+
* merged bucket left unacked would resurrect on unmute/reopen). */
|
|
133
|
+
unreadBuckets(): Array<{ key: string; lastSeq: number }> {
|
|
134
|
+
const rows: Array<{ key: string; lastSeq: number }> = [];
|
|
135
|
+
for (const [key, bucket] of this.buckets) {
|
|
136
|
+
if (bucket.unreadCount > 0 && bucket.lastSeq > bucket.cursor) {
|
|
137
|
+
rows.push({ key, lastSeq: bucket.lastSeq });
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return rows;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
attentionFor(topicId: string): RoomTopicAttention {
|
|
144
|
+
return this.bucketFor(topicId).attention;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** A registry mutation arrived (TOPIC_UPDATED, or a verb result echo). */
|
|
148
|
+
applyTopicUpdated(topic: RoomTopic): void {
|
|
149
|
+
this.topics.set(topic.topicId, topic);
|
|
150
|
+
if (!this.buckets.has(topic.topicId)) {
|
|
151
|
+
this.buckets.set(topic.topicId, emptyBucket());
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* A live entry arrived. viewedKey is the bucket the viewer is CURRENTLY
|
|
157
|
+
* looking at (or null): entries landing in the viewed bucket advance the
|
|
158
|
+
* local cursor instead of counting unread (the durable ACK rides the
|
|
159
|
+
* view batcher separately). Own posts never count unread, and an own
|
|
160
|
+
* post into a topic mirrors the server's auto-follow (mute survives).
|
|
161
|
+
*/
|
|
162
|
+
applyEntry(
|
|
163
|
+
entry: TopicInboxEntry,
|
|
164
|
+
context: { selfUserId: string; viewedKey?: string | null },
|
|
165
|
+
): void {
|
|
166
|
+
if (!Number.isFinite(entry.seq) || entry.seq <= 0) return;
|
|
167
|
+
const key = entry.topicId || UNTOPICKED_BUCKET;
|
|
168
|
+
let bucket = this.buckets.get(key);
|
|
169
|
+
if (!bucket) {
|
|
170
|
+
bucket = emptyBucket();
|
|
171
|
+
this.buckets.set(key, bucket);
|
|
172
|
+
}
|
|
173
|
+
bucket.lastSeq = Math.max(bucket.lastSeq, entry.seq);
|
|
174
|
+
this.headSeq = Math.max(this.headSeq, entry.seq);
|
|
175
|
+
|
|
176
|
+
const own = entry.userId === context.selfUserId;
|
|
177
|
+
if (own) {
|
|
178
|
+
if (key !== UNTOPICKED_BUCKET && bucket.attention !== "mute") {
|
|
179
|
+
bucket.attention = "follow";
|
|
180
|
+
}
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
if (context.viewedKey === key) {
|
|
184
|
+
bucket.cursor = Math.max(bucket.cursor, entry.seq);
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
bucket.unreadCount += 1;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Mirror an attention change (a TOPIC_SET_ATTENTION result, optimistic
|
|
191
|
+
* or confirmed). */
|
|
192
|
+
applyAttention(topicId: string, attention: RoomTopicAttention): void {
|
|
193
|
+
let bucket = this.buckets.get(topicId);
|
|
194
|
+
if (!bucket) {
|
|
195
|
+
bucket = emptyBucket();
|
|
196
|
+
this.buckets.set(topicId, bucket);
|
|
197
|
+
}
|
|
198
|
+
bucket.attention = attention;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* The viewer saw a bucket up to seq (the view batcher owns the durable
|
|
203
|
+
* ACK; this mirrors it locally). Catching up to the bucket head clears
|
|
204
|
+
* its count; a partial view keeps the residual count as an upper bound
|
|
205
|
+
* until the next hydration trues it up.
|
|
206
|
+
*/
|
|
207
|
+
noteViewed(key: string, seq: number): void {
|
|
208
|
+
if (!Number.isFinite(seq) || seq <= 0) return;
|
|
209
|
+
let bucket = this.buckets.get(key);
|
|
210
|
+
if (!bucket) {
|
|
211
|
+
bucket = emptyBucket();
|
|
212
|
+
this.buckets.set(key, bucket);
|
|
213
|
+
}
|
|
214
|
+
bucket.cursor = Math.max(bucket.cursor, seq);
|
|
215
|
+
if (bucket.cursor >= bucket.lastSeq) bucket.unreadCount = 0;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* The channel-level unread badge for a topic-aware room: every non-muted
|
|
220
|
+
* bucket's count, untopicked and resolved included (new activity in a
|
|
221
|
+
* resolved topic still deserves attention), husks counted once (their
|
|
222
|
+
* unread also rolls into the survivor's inbox item for display).
|
|
223
|
+
*/
|
|
224
|
+
channelUnread(): number {
|
|
225
|
+
let total = 0;
|
|
226
|
+
for (const bucket of this.buckets.values()) {
|
|
227
|
+
if (bucket.attention === "mute") continue;
|
|
228
|
+
total += bucket.unreadCount;
|
|
229
|
+
}
|
|
230
|
+
return total;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* The inbox, in THE deterministic catch-up order every surface shares:
|
|
235
|
+
* the untopicked lane first (the channel's main stream), then followed
|
|
236
|
+
* topics, then the rest; within each group oldest activity first
|
|
237
|
+
* (ascending lastSeq, topicId tie-break). Muted buckets never appear;
|
|
238
|
+
* merged husks roll their unread into the survivor's aliasUnread; a
|
|
239
|
+
* resolved topic appears only while it carries unread.
|
|
240
|
+
*/
|
|
241
|
+
inboxItems(): TopicInboxItem[] {
|
|
242
|
+
const items: TopicInboxItem[] = [];
|
|
243
|
+
const untopicked = this.bucketFor(UNTOPICKED_BUCKET);
|
|
244
|
+
if (untopicked.unreadCount > 0 && untopicked.attention !== "mute") {
|
|
245
|
+
items.push({
|
|
246
|
+
key: UNTOPICKED_BUCKET,
|
|
247
|
+
topic: undefined,
|
|
248
|
+
state: { ...untopicked },
|
|
249
|
+
aliasUnread: 0,
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const grouped: TopicInboxItem[] = [];
|
|
254
|
+
for (const topic of this.topics.values()) {
|
|
255
|
+
if (topic.mergedInto) continue;
|
|
256
|
+
const state = this.bucketFor(topic.topicId);
|
|
257
|
+
if (state.attention === "mute") continue;
|
|
258
|
+
let aliasUnread = 0;
|
|
259
|
+
for (const alias of this.aliasesOf(topic.topicId)) {
|
|
260
|
+
const aliasState = this.bucketFor(alias);
|
|
261
|
+
if (aliasState.attention === "mute") continue;
|
|
262
|
+
aliasUnread += aliasState.unreadCount;
|
|
263
|
+
}
|
|
264
|
+
if (state.unreadCount + aliasUnread === 0) continue;
|
|
265
|
+
grouped.push({ key: topic.topicId, topic, state: { ...state }, aliasUnread });
|
|
266
|
+
}
|
|
267
|
+
grouped.sort((a, b) => {
|
|
268
|
+
const aFollow = a.state.attention === "follow" ? 0 : 1;
|
|
269
|
+
const bFollow = b.state.attention === "follow" ? 0 : 1;
|
|
270
|
+
if (aFollow !== bFollow) return aFollow - bFollow;
|
|
271
|
+
if (a.state.lastSeq !== b.state.lastSeq) {
|
|
272
|
+
return a.state.lastSeq - b.state.lastSeq;
|
|
273
|
+
}
|
|
274
|
+
return a.key < b.key ? -1 : a.key > b.key ? 1 : 0;
|
|
275
|
+
});
|
|
276
|
+
items.push(...grouped);
|
|
277
|
+
return items;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* The next bucket a catch-up walk should visit: the first inbox item
|
|
282
|
+
* after the given key (wrapping), or null when nothing is unread. The
|
|
283
|
+
* walk order is inboxItems(); keyboard nav and the catch-up flow step
|
|
284
|
+
* through the same list a user sees.
|
|
285
|
+
*/
|
|
286
|
+
nextUnread(afterKey?: string | null): string | null {
|
|
287
|
+
const items = this.inboxItems();
|
|
288
|
+
if (items.length === 0) return null;
|
|
289
|
+
if (afterKey === undefined || afterKey === null) {
|
|
290
|
+
return items[0]?.key ?? null;
|
|
291
|
+
}
|
|
292
|
+
const index = items.findIndex((item) => item.key === afterKey);
|
|
293
|
+
if (index < 0) return items[0]?.key ?? null;
|
|
294
|
+
const next = items[(index + 1) % items.length];
|
|
295
|
+
return next && next.key !== afterKey ? next.key : null;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* View-time cursor batching: the ONLY path from "a surface is showing
|
|
3
|
+
* messages" to a cursor ACK. Surfaces report views (beginView, noteSeq,
|
|
4
|
+
* endView); this batcher applies the locked phase 2 contract on top:
|
|
5
|
+
*
|
|
6
|
+
* - Dwell gating: a view must survive dwellMs before anything is acked, so
|
|
7
|
+
* a keyboard flash-through (next-unread skimming past a topic) never
|
|
8
|
+
* marks it read. "Marks read only after actual view time."
|
|
9
|
+
* - Batching: once dwelled, the highest seen seq forwards into the
|
|
10
|
+
* downstream ACK debouncer (one ACK frame per bucket per debounce
|
|
11
|
+
* window at most, relay-side write buffering below that). Per-message
|
|
12
|
+
* cursor writes are impossible by construction: the UI layer has no
|
|
13
|
+
* other ack path.
|
|
14
|
+
*
|
|
15
|
+
* A bucket key of '' (UNTOPICKED_BUCKET) acks the room-wide cursor; any
|
|
16
|
+
* other key acks that topic's cursor.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export interface TopicViewBatcherOptions {
|
|
20
|
+
/** Forward a dwelled view position; wire this to RoomAckDebouncer.schedule. */
|
|
21
|
+
scheduleAck: (room: string, seq: number, topicId?: string) => void;
|
|
22
|
+
/** Continuous view time required before the first ack (default 600ms). */
|
|
23
|
+
dwellMs?: number | undefined;
|
|
24
|
+
/** Injectable timers for tests. */
|
|
25
|
+
setTimeoutFn?: ((fn: () => void, ms: number) => unknown) | undefined;
|
|
26
|
+
clearTimeoutFn?: ((handle: unknown) => void) | undefined;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface ViewSession {
|
|
30
|
+
room: string;
|
|
31
|
+
key: string;
|
|
32
|
+
maxSeq: number;
|
|
33
|
+
dwelled: boolean;
|
|
34
|
+
timer: unknown;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function viewKey(room: string, key: string): string {
|
|
38
|
+
return `${encodeURIComponent(room)}:${encodeURIComponent(key)}`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export class TopicViewBatcher {
|
|
42
|
+
private readonly scheduleAck: TopicViewBatcherOptions["scheduleAck"];
|
|
43
|
+
private readonly dwellMs: number;
|
|
44
|
+
private readonly setTimeoutFn: (fn: () => void, ms: number) => unknown;
|
|
45
|
+
private readonly clearTimeoutFn: (handle: unknown) => void;
|
|
46
|
+
private readonly sessions = new Map<string, ViewSession>();
|
|
47
|
+
|
|
48
|
+
constructor(options: TopicViewBatcherOptions) {
|
|
49
|
+
this.scheduleAck = options.scheduleAck;
|
|
50
|
+
this.dwellMs = options.dwellMs ?? 600;
|
|
51
|
+
this.setTimeoutFn =
|
|
52
|
+
options.setTimeoutFn ??
|
|
53
|
+
((fn, ms) => {
|
|
54
|
+
const timer = setTimeout(fn, ms);
|
|
55
|
+
(timer as { unref?: () => void }).unref?.();
|
|
56
|
+
return timer;
|
|
57
|
+
});
|
|
58
|
+
this.clearTimeoutFn = options.clearTimeoutFn ?? ((handle) => clearTimeout(handle as never));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** A surface started showing a bucket. Idempotent per (room, key). */
|
|
62
|
+
beginView(room: string, key: string): void {
|
|
63
|
+
const id = viewKey(room, key);
|
|
64
|
+
if (this.sessions.has(id)) return;
|
|
65
|
+
const session: ViewSession = { room, key, maxSeq: 0, dwelled: false, timer: undefined };
|
|
66
|
+
session.timer = this.setTimeoutFn(() => {
|
|
67
|
+
session.dwelled = true;
|
|
68
|
+
session.timer = undefined;
|
|
69
|
+
this.forward(session);
|
|
70
|
+
}, this.dwellMs);
|
|
71
|
+
this.sessions.set(id, session);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The highest seq currently visible in a bucket the surface is showing.
|
|
75
|
+
* Before dwell it accumulates; after dwell it forwards (debounced
|
|
76
|
+
* downstream). Calls for a bucket not being viewed are ignored: only
|
|
77
|
+
* actual view time acks. */
|
|
78
|
+
noteSeq(room: string, key: string, seq: number): void {
|
|
79
|
+
if (!Number.isFinite(seq) || seq <= 0) return;
|
|
80
|
+
const session = this.sessions.get(viewKey(room, key));
|
|
81
|
+
if (!session) return;
|
|
82
|
+
if (seq <= session.maxSeq) return;
|
|
83
|
+
session.maxSeq = seq;
|
|
84
|
+
if (session.dwelled) this.forward(session);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The surface stopped showing the bucket. An un-dwelled view (a
|
|
88
|
+
* flash-through) is discarded without acking. */
|
|
89
|
+
endView(room: string, key: string): void {
|
|
90
|
+
const id = viewKey(room, key);
|
|
91
|
+
const session = this.sessions.get(id);
|
|
92
|
+
if (!session) return;
|
|
93
|
+
if (session.timer !== undefined) {
|
|
94
|
+
this.clearTimeoutFn(session.timer);
|
|
95
|
+
session.timer = undefined;
|
|
96
|
+
}
|
|
97
|
+
this.sessions.delete(id);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** True while the bucket is being viewed (dwelled or not). */
|
|
101
|
+
isViewing(room: string, key: string): boolean {
|
|
102
|
+
return this.sessions.has(viewKey(room, key));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** The bucket key currently viewed in a room, or null. */
|
|
106
|
+
viewedKey(room: string): string | null {
|
|
107
|
+
for (const session of this.sessions.values()) {
|
|
108
|
+
if (session.room === room) return session.key;
|
|
109
|
+
}
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Force every dwelled view forward (window hide, disconnect boundary).
|
|
114
|
+
* Un-dwelled views stay pending: flushing must not turn a flash-through
|
|
115
|
+
* into a read. */
|
|
116
|
+
flush(): void {
|
|
117
|
+
for (const session of this.sessions.values()) {
|
|
118
|
+
if (session.dwelled) this.forward(session);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
close(): void {
|
|
123
|
+
for (const session of this.sessions.values()) {
|
|
124
|
+
if (session.timer !== undefined) this.clearTimeoutFn(session.timer);
|
|
125
|
+
}
|
|
126
|
+
this.flush();
|
|
127
|
+
this.sessions.clear();
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
private forward(session: ViewSession): void {
|
|
131
|
+
if (session.maxSeq <= 0) return;
|
|
132
|
+
this.scheduleAck(session.room, session.maxSeq, session.key === "" ? undefined : session.key);
|
|
133
|
+
}
|
|
134
|
+
}
|