@convokitapp/vue-ui 0.2.2 → 0.4.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/CHANGELOG.md +29 -0
- package/PARITY.md +8 -1
- package/README.md +63 -1
- package/dist/index.cjs +942 -396
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +35 -33
- package/dist/index.d.ts +35 -33
- package/dist/index.js +826 -296
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
package/dist/index.js
CHANGED
|
@@ -1,12 +1,25 @@
|
|
|
1
1
|
// src/client.ts
|
|
2
2
|
function createConvoKitUiClient(client) {
|
|
3
3
|
return {
|
|
4
|
+
get sessionIdentity() {
|
|
5
|
+
return client.connected ? client.realtime : null;
|
|
6
|
+
},
|
|
7
|
+
onConnectionEvent: (handlers) => client.realtime.onConnectionEvent(handlers),
|
|
8
|
+
onInboxChanged: (handler, onError) => client.realtime.onInboxChanged(client.clientId, {
|
|
9
|
+
onEvent: handler,
|
|
10
|
+
...onError ? { onError } : {}
|
|
11
|
+
}),
|
|
12
|
+
onMessageDeleted: (conversationId, handler, onError) => client.realtime.onMessageDeleted(conversationId, {
|
|
13
|
+
onEvent: handler,
|
|
14
|
+
...onError ? { onError } : {}
|
|
15
|
+
}),
|
|
4
16
|
get currentUserId() {
|
|
5
|
-
return client.currentUserId;
|
|
17
|
+
return client.connected ? client.currentUserId : "";
|
|
6
18
|
},
|
|
7
19
|
getConversations: (options) => client.getConversations(options),
|
|
8
20
|
getConversation: (conversationId) => client.getConversation(conversationId),
|
|
9
21
|
getMessages: (options) => client.getMessages(options),
|
|
22
|
+
getMessage: (id) => client.getMessage(id),
|
|
10
23
|
sendMessage: (input) => client.sendMessage(input),
|
|
11
24
|
markConversationRead: (conversationId) => client.markConversationRead(conversationId),
|
|
12
25
|
sendTyping: (input) => client.sendTyping(input),
|
|
@@ -31,12 +44,13 @@ import { defineComponent, h } from "vue";
|
|
|
31
44
|
|
|
32
45
|
// src/utils.ts
|
|
33
46
|
import { clsx } from "clsx";
|
|
47
|
+
import { normalizeClass } from "vue";
|
|
34
48
|
var pendingMessageIdPrefix = "convokit-pending-";
|
|
35
49
|
function isConvoKitPendingMessage(message) {
|
|
36
50
|
return message.id.startsWith(pendingMessageIdPrefix);
|
|
37
51
|
}
|
|
38
52
|
function cx(...values) {
|
|
39
|
-
return clsx(values);
|
|
53
|
+
return clsx(values.map(normalizeClass));
|
|
40
54
|
}
|
|
41
55
|
function requestedParticipantIds(filter) {
|
|
42
56
|
const values = filter.participantIds ?? [];
|
|
@@ -137,253 +151,617 @@ import { ArrowLeft, LoaderCircle as LoaderCircle2, Paperclip, RefreshCw, Send }
|
|
|
137
151
|
import {
|
|
138
152
|
defineComponent as defineComponent3,
|
|
139
153
|
h as h3,
|
|
140
|
-
ref as
|
|
154
|
+
ref as ref2,
|
|
141
155
|
watchEffect
|
|
142
156
|
} from "vue";
|
|
143
157
|
|
|
144
158
|
// src/composables/use-conversation.ts
|
|
145
|
-
import {
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
let subscriptions = [];
|
|
176
|
-
const pendingIds = /* @__PURE__ */ new Set();
|
|
177
|
-
let pendingSequence = 0;
|
|
178
|
-
const unsubscribe = async () => {
|
|
179
|
-
const active = subscriptions;
|
|
180
|
-
subscriptions = [];
|
|
181
|
-
await Promise.all(active.map((subscription) => subscription.unsubscribe()));
|
|
159
|
+
import { computed, getCurrentScope, onScopeDispose, shallowRef, toValue, watch } from "vue";
|
|
160
|
+
|
|
161
|
+
// src/conversation-store.ts
|
|
162
|
+
import { createClientMessageId } from "@convokitapp/sdk";
|
|
163
|
+
function version(message) {
|
|
164
|
+
return message.updatedAt?.getTime() ?? message.createdAt.getTime();
|
|
165
|
+
}
|
|
166
|
+
function hasContent(message) {
|
|
167
|
+
return !!message.text?.trim() || message.media.length > 0;
|
|
168
|
+
}
|
|
169
|
+
function newest(current, incoming, incomingComplete = true) {
|
|
170
|
+
return version(current) > version(incoming) || !incomingComplete && version(current) === version(incoming) ? current : incoming;
|
|
171
|
+
}
|
|
172
|
+
function compare(left, right) {
|
|
173
|
+
return left.createdAt.getTime() - right.createdAt.getTime() || (left.id < right.id ? -1 : left.id > right.id ? 1 : 0);
|
|
174
|
+
}
|
|
175
|
+
function blank(currentUserId = "") {
|
|
176
|
+
return {
|
|
177
|
+
conversation: null,
|
|
178
|
+
messages: [],
|
|
179
|
+
typingUserIds: /* @__PURE__ */ new Set(),
|
|
180
|
+
readAtByUserId: /* @__PURE__ */ new Map(),
|
|
181
|
+
isInitialLoading: false,
|
|
182
|
+
isLoadingOlder: false,
|
|
183
|
+
isReconciling: false,
|
|
184
|
+
isSending: false,
|
|
185
|
+
hasOlderMessages: true,
|
|
186
|
+
hasLoaded: false,
|
|
187
|
+
error: null,
|
|
188
|
+
currentUserId
|
|
182
189
|
};
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
190
|
+
}
|
|
191
|
+
var ConversationStore = class {
|
|
192
|
+
constructor(options) {
|
|
193
|
+
this.options = options;
|
|
194
|
+
this.client = options.client;
|
|
195
|
+
this.room = options.conversationId.trim();
|
|
196
|
+
this.pageSize = options.messagePageSize ?? 30;
|
|
197
|
+
this.typingTimeout = options.typingTimeoutMs ?? 3e3;
|
|
198
|
+
if (!this.room) throw new TypeError("conversationId is required");
|
|
199
|
+
if (!Number.isInteger(this.pageSize) || this.pageSize < 1 || this.pageSize > 100) {
|
|
200
|
+
throw new RangeError("messagePageSize must be an integer between 1 and 100");
|
|
201
|
+
}
|
|
202
|
+
if (!Number.isFinite(this.typingTimeout) || this.typingTimeout < 0) {
|
|
203
|
+
throw new RangeError("typingTimeoutMs must be non-negative");
|
|
204
|
+
}
|
|
205
|
+
this.owner = this.client.sessionIdentity;
|
|
206
|
+
this.user = this.owner ? this.client.currentUserId : "";
|
|
207
|
+
this.state = blank(this.user);
|
|
208
|
+
}
|
|
209
|
+
options;
|
|
210
|
+
client;
|
|
211
|
+
room;
|
|
212
|
+
owner;
|
|
213
|
+
user;
|
|
214
|
+
pageSize;
|
|
215
|
+
typingTimeout;
|
|
216
|
+
state;
|
|
217
|
+
listeners = /* @__PURE__ */ new Set();
|
|
218
|
+
subscriptions = [];
|
|
219
|
+
disposed = true;
|
|
220
|
+
generation = 0;
|
|
221
|
+
cursor;
|
|
222
|
+
revision = 0;
|
|
223
|
+
changes = /* @__PURE__ */ new Map();
|
|
224
|
+
hydrations = /* @__PURE__ */ new Map();
|
|
225
|
+
hydrationPool = { running: /* @__PURE__ */ new Set(), queued: /* @__PURE__ */ new Map() };
|
|
226
|
+
// Keep tombstones until an explicit reload/session change, including across refreshes.
|
|
227
|
+
deleted = /* @__PURE__ */ new Set();
|
|
228
|
+
sendRevision;
|
|
229
|
+
activeSend;
|
|
230
|
+
refreshQueued = false;
|
|
231
|
+
typingTimers = /* @__PURE__ */ new Map();
|
|
232
|
+
ownTypingTimer;
|
|
233
|
+
sentTyping = false;
|
|
234
|
+
typingRevision = 0;
|
|
235
|
+
lastTypingSentAt = -Infinity;
|
|
236
|
+
getSnapshot = () => this.state;
|
|
237
|
+
subscribe = (listener) => {
|
|
238
|
+
this.listeners.add(listener);
|
|
239
|
+
return () => {
|
|
240
|
+
this.listeners.delete(listener);
|
|
241
|
+
};
|
|
242
|
+
};
|
|
243
|
+
patch(patch) {
|
|
244
|
+
if (patch.messages && this.activeSend) {
|
|
245
|
+
for (const message of patch.messages) this.confirmSend(message);
|
|
246
|
+
if (this.activeSend.confirmed) patch.messages = patch.messages.filter((message) => message.id !== this.activeSend.pending.id);
|
|
247
|
+
}
|
|
248
|
+
this.state = { ...this.state, ...patch };
|
|
249
|
+
for (const listener of this.listeners) listener();
|
|
250
|
+
}
|
|
251
|
+
alive(generation = this.generation) {
|
|
252
|
+
return !this.disposed && generation === this.generation && this.owner !== null && this.client.sessionIdentity === this.owner;
|
|
253
|
+
}
|
|
254
|
+
start = (autoLoad = true) => {
|
|
255
|
+
if (this.owner === null || this.client.sessionIdentity !== this.owner) return;
|
|
256
|
+
this.disposed = false;
|
|
257
|
+
this.patch({ currentUserId: this.user });
|
|
258
|
+
if (autoLoad) void this.loadInitial();
|
|
259
|
+
else {
|
|
260
|
+
try {
|
|
261
|
+
this.attach(this.generation, false);
|
|
262
|
+
} catch {
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
};
|
|
266
|
+
detach() {
|
|
267
|
+
const active = this.subscriptions;
|
|
268
|
+
this.subscriptions = [];
|
|
269
|
+
for (const subscription of active) void subscription.unsubscribe().catch(() => void 0);
|
|
270
|
+
}
|
|
271
|
+
clearTyping() {
|
|
272
|
+
for (const timer of this.typingTimers.values()) clearTimeout(timer);
|
|
273
|
+
this.typingTimers.clear();
|
|
274
|
+
clearTimeout(this.ownTypingTimer);
|
|
275
|
+
this.ownTypingTimer = void 0;
|
|
276
|
+
this.sentTyping = false;
|
|
277
|
+
this.typingRevision++;
|
|
278
|
+
this.lastTypingSentAt = -Infinity;
|
|
279
|
+
this.patch({ typingUserIds: /* @__PURE__ */ new Set() });
|
|
280
|
+
}
|
|
281
|
+
clear() {
|
|
282
|
+
this.generation++;
|
|
283
|
+
this.detach();
|
|
284
|
+
this.clearTyping();
|
|
285
|
+
this.cursor = void 0;
|
|
286
|
+
this.changes.clear();
|
|
287
|
+
this.hydrations.clear();
|
|
288
|
+
this.hydrationPool.queued.clear();
|
|
289
|
+
this.deleted.clear();
|
|
290
|
+
this.sendRevision = void 0;
|
|
291
|
+
this.activeSend = void 0;
|
|
292
|
+
this.refreshQueued = false;
|
|
293
|
+
}
|
|
294
|
+
dispose = () => {
|
|
295
|
+
if (this.alive() && this.sentTyping) {
|
|
296
|
+
void this.client.sendTyping({ conversationId: this.room, isTyping: false }).catch(() => void 0);
|
|
192
297
|
}
|
|
298
|
+
this.disposed = true;
|
|
299
|
+
this.clear();
|
|
300
|
+
this.patch(blank());
|
|
193
301
|
};
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
302
|
+
fail(cause, generation, history = false) {
|
|
303
|
+
if (!this.alive(generation)) return;
|
|
304
|
+
const status = typeof cause === "object" && cause !== null && "status" in cause ? cause.status : void 0;
|
|
305
|
+
if (history && (status === 401 || status === 403 || status === 404)) {
|
|
306
|
+
this.clear();
|
|
307
|
+
this.patch({ ...blank(this.user), error: cause, hasLoaded: true, hasOlderMessages: false });
|
|
308
|
+
} else this.patch({ error: cause });
|
|
309
|
+
}
|
|
310
|
+
attach(generation, data = true) {
|
|
311
|
+
const report = (cause) => this.fail(cause, generation);
|
|
312
|
+
const add = (create) => {
|
|
313
|
+
if (!this.alive(generation)) return;
|
|
314
|
+
const subscription = create();
|
|
315
|
+
if (this.alive(generation)) this.subscriptions.push(subscription);
|
|
316
|
+
else void subscription.unsubscribe().catch(() => void 0);
|
|
197
317
|
};
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
}
|
|
220
|
-
|
|
318
|
+
try {
|
|
319
|
+
add(() => this.client.onConnectionEvent({
|
|
320
|
+
onEvent: ({ topic, status }) => {
|
|
321
|
+
if (!data || !this.alive(generation) || topic !== `messages:${this.room}` && topic !== `conversation:${this.room}`) return;
|
|
322
|
+
if (status === "SUBSCRIBED") this.queueRefresh();
|
|
323
|
+
else this.clearTyping();
|
|
324
|
+
},
|
|
325
|
+
onSessionEnded: () => {
|
|
326
|
+
if (this.disposed || generation !== this.generation) return;
|
|
327
|
+
this.dispose();
|
|
328
|
+
},
|
|
329
|
+
onError: report
|
|
330
|
+
}));
|
|
331
|
+
if (!data) return;
|
|
332
|
+
add(() => this.client.onInboxChanged(() => {
|
|
333
|
+
if (this.alive(generation)) this.queueRefresh();
|
|
334
|
+
}, (cause) => {
|
|
335
|
+
if (this.alive(generation)) {
|
|
336
|
+
report(cause);
|
|
337
|
+
this.queueRefresh();
|
|
338
|
+
}
|
|
339
|
+
}));
|
|
340
|
+
add(() => this.client.onMessage(this.room, (event) => this.onMessage(event, generation), report));
|
|
341
|
+
add(() => this.client.onMessageDeleted(this.room, ({ id, conversationId }) => {
|
|
342
|
+
if (!this.alive(generation) || conversationId !== this.room || !id.trim()) return;
|
|
343
|
+
this.removeMessage(id);
|
|
344
|
+
}, report));
|
|
345
|
+
add(() => this.client.onReadReceipt(this.room, ({ userId, readAt }) => {
|
|
346
|
+
if (this.alive(generation)) this.mergeReads([[userId, readAt]]);
|
|
347
|
+
}, report));
|
|
348
|
+
add(() => this.client.onTyping(this.room, ({ userId, isTyping }) => {
|
|
349
|
+
if (!this.alive(generation) || !userId.trim() || userId === this.user) return;
|
|
350
|
+
clearTimeout(this.typingTimers.get(userId));
|
|
351
|
+
this.typingTimers.delete(userId);
|
|
352
|
+
const next = new Set(this.state.typingUserIds);
|
|
353
|
+
if (isTyping) {
|
|
354
|
+
next.add(userId);
|
|
355
|
+
this.typingTimers.set(userId, setTimeout(() => {
|
|
356
|
+
this.typingTimers.delete(userId);
|
|
357
|
+
if (!this.alive(generation)) return;
|
|
358
|
+
const remaining = new Set(this.state.typingUserIds);
|
|
359
|
+
remaining.delete(userId);
|
|
360
|
+
this.patch({ typingUserIds: remaining });
|
|
361
|
+
}, this.typingTimeout));
|
|
362
|
+
} else next.delete(userId);
|
|
363
|
+
this.patch({ typingUserIds: next });
|
|
364
|
+
}, report));
|
|
365
|
+
} catch (cause) {
|
|
366
|
+
this.detach();
|
|
367
|
+
this.fail(cause, generation);
|
|
368
|
+
throw cause;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
validMessage(message) {
|
|
372
|
+
return message.conversationId === this.room && !!message.id.trim() && !!message.senderId.trim() && !isConvoKitPendingMessage(message) && Number.isFinite(message.createdAt.getTime()) && Number.isFinite(version(message));
|
|
373
|
+
}
|
|
374
|
+
onMessage(event, generation) {
|
|
375
|
+
const { message, type } = event;
|
|
376
|
+
if (!this.alive(generation) || type !== "insert" && type !== "update" || !this.validMessage(message) || this.deleted.has(message.id)) return;
|
|
377
|
+
const existing = this.state.messages.find((item) => item.id === message.id);
|
|
378
|
+
const known = existing ?? this.changes.get(message.id)?.message;
|
|
379
|
+
if (known && version(message) < version(known)) return;
|
|
380
|
+
const insert = type === "insert" || this.changes.get(message.id)?.insert === true;
|
|
381
|
+
if (!existing && !insert && type === "update" && !this.state.isInitialLoading && !this.state.isLoadingOlder && !this.state.isReconciling) return;
|
|
382
|
+
const revision = ++this.revision;
|
|
383
|
+
const provisional = existing && !message.media.length ? { ...message, media: existing.media } : message;
|
|
384
|
+
this.record(provisional, insert, revision, false);
|
|
385
|
+
const job = { revision, generation, message, insert };
|
|
386
|
+
this.hydrations.set(message.id, job);
|
|
387
|
+
this.hydrationPool.queued.set(message.id, job);
|
|
388
|
+
this.drainHydration();
|
|
389
|
+
if (type === "insert" && !existing && message.senderId !== this.user && (this.options.markReadOnReceive ?? true)) {
|
|
390
|
+
void this.markRead();
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
record(message, insert, revision, complete) {
|
|
394
|
+
const existing = this.state.messages.find((item) => item.id === message.id);
|
|
395
|
+
if (existing && version(existing) > version(message)) return;
|
|
396
|
+
if (this.confirmSend(message) && !complete && !message.media.length) {
|
|
397
|
+
message = { ...message, media: this.activeSend.pending.media };
|
|
398
|
+
this.confirmSend(message);
|
|
399
|
+
}
|
|
400
|
+
this.changes.set(message.id, { revision, message, insert, complete });
|
|
401
|
+
if (existing || insert && hasContent(message)) this.patch({ messages: mergeMessages(this.state.messages, [message]) });
|
|
402
|
+
}
|
|
403
|
+
confirmSend(message) {
|
|
404
|
+
const send = this.activeSend;
|
|
405
|
+
if (!send || !this.validMessage(message) || message.senderId !== this.user || message.clientMessageId !== send.pending.clientMessageId) return false;
|
|
406
|
+
send.confirmed = send.confirmed ? newest(send.confirmed, message) : message;
|
|
407
|
+
return true;
|
|
408
|
+
}
|
|
409
|
+
currentHydration(job) {
|
|
410
|
+
return this.alive(job.generation) && !this.deleted.has(job.message.id) && this.hydrations.get(job.message.id) === job;
|
|
411
|
+
}
|
|
412
|
+
removeMessage(id) {
|
|
413
|
+
this.deleted.add(id);
|
|
414
|
+
this.changes.delete(id);
|
|
415
|
+
this.hydrations.delete(id);
|
|
416
|
+
this.hydrationPool.queued.delete(id);
|
|
417
|
+
this.patch({ messages: this.state.messages.filter((message) => message.id !== id) });
|
|
418
|
+
}
|
|
419
|
+
drainHydration() {
|
|
420
|
+
const pool = this.hydrationPool;
|
|
421
|
+
for (const [id, job] of pool.queued) {
|
|
422
|
+
if (pool.running.size >= 8) break;
|
|
423
|
+
if (pool.running.has(id)) continue;
|
|
424
|
+
pool.queued.delete(id);
|
|
425
|
+
if (!this.currentHydration(job)) continue;
|
|
426
|
+
pool.running.add(id);
|
|
427
|
+
void this.hydrate(job, pool);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
async hydrate(job, pool) {
|
|
431
|
+
const id = job.message.id;
|
|
432
|
+
try {
|
|
433
|
+
if (!this.currentHydration(job)) return;
|
|
434
|
+
const full = await this.client.getMessage(id);
|
|
435
|
+
if (!this.currentHydration(job)) return;
|
|
436
|
+
if (!this.validMessage(full) || full.id !== id || full.senderId !== job.message.senderId || version(full) < version(job.message)) {
|
|
437
|
+
throw new Error("Complete message response does not match the observed resource/revision");
|
|
438
|
+
}
|
|
439
|
+
this.record(full, job.insert, job.revision, true);
|
|
440
|
+
} catch (cause) {
|
|
441
|
+
if (!this.currentHydration(job)) return;
|
|
442
|
+
const status = typeof cause === "object" && cause !== null && "status" in cause ? cause.status : void 0;
|
|
443
|
+
if (status === 404) this.removeMessage(id);
|
|
444
|
+
this.fail(cause, job.generation);
|
|
445
|
+
this.queueRefresh();
|
|
446
|
+
} finally {
|
|
447
|
+
if (this.hydrations.get(id) === job) this.hydrations.delete(id);
|
|
448
|
+
pool.running.delete(id);
|
|
449
|
+
queueMicrotask(() => {
|
|
450
|
+
if (this.hydrationPool === pool) this.drainHydration();
|
|
451
|
+
});
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
mergeReads(entries) {
|
|
455
|
+
const next = new Map(this.state.readAtByUserId);
|
|
456
|
+
for (const [userId, readAt] of entries) {
|
|
457
|
+
if (!userId.trim() || !Number.isFinite(readAt.getTime())) continue;
|
|
458
|
+
if (readAt.getTime() > (next.get(userId)?.getTime() ?? -Infinity)) next.set(userId, readAt);
|
|
459
|
+
}
|
|
460
|
+
this.patch({ readAtByUserId: next });
|
|
461
|
+
}
|
|
462
|
+
validatePage(page, before) {
|
|
463
|
+
if (page.length > this.pageSize) throw new Error("Message page exceeds the requested limit");
|
|
464
|
+
let previous = before;
|
|
465
|
+
for (const message of page) {
|
|
466
|
+
if (!this.validMessage(message) || previous && compare(message, previous) >= 0) {
|
|
467
|
+
throw new Error("Message history must contain distinct, room-scoped rows in newest-first cursor order");
|
|
468
|
+
}
|
|
469
|
+
previous = message;
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
fetchPage(before) {
|
|
473
|
+
return this.client.getMessages({
|
|
474
|
+
conversationId: this.room,
|
|
475
|
+
limit: this.pageSize,
|
|
476
|
+
...before ? { beforeCreatedAt: before.createdAt, beforeId: before.id } : {}
|
|
477
|
+
});
|
|
478
|
+
}
|
|
479
|
+
overlay(rows, revision) {
|
|
480
|
+
const byId = new Map(rows.filter((message) => !this.deleted.has(message.id)).map((message) => [message.id, message]));
|
|
481
|
+
for (const [id, change] of this.changes) {
|
|
482
|
+
if (change.revision > revision && !this.deleted.has(id) && (change.insert || byId.has(id))) {
|
|
483
|
+
const current = byId.get(id);
|
|
484
|
+
if (current || change.complete || hasContent(change.message)) {
|
|
485
|
+
byId.set(id, current ? newest(current, change.message, change.complete) : change.message);
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
for (const message of this.state.messages) {
|
|
490
|
+
if (isConvoKitPendingMessage(message)) byId.set(message.id, message);
|
|
491
|
+
}
|
|
492
|
+
return mergeMessages([], [...byId.values()]);
|
|
493
|
+
}
|
|
494
|
+
prune(revision) {
|
|
495
|
+
const safeRevision = Math.min(revision, this.sendRevision ?? Infinity);
|
|
496
|
+
for (const [id, change] of this.changes) if (change.revision <= safeRevision) this.changes.delete(id);
|
|
497
|
+
for (const [id, job] of this.hydrations) if (job.revision <= revision) {
|
|
498
|
+
this.hydrations.delete(id);
|
|
499
|
+
this.hydrationPool.queued.delete(id);
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
loadInitial = async () => {
|
|
503
|
+
if (!this.alive()) return;
|
|
504
|
+
this.clear();
|
|
505
|
+
const generation = this.generation;
|
|
506
|
+
this.patch({ ...blank(this.user), isInitialLoading: true });
|
|
507
|
+
const revision = this.revision;
|
|
508
|
+
try {
|
|
509
|
+
this.attach(generation);
|
|
510
|
+
if (!this.alive(generation)) return;
|
|
511
|
+
const [conversation, page] = await Promise.all([this.client.getConversation(this.room), this.fetchPage()]);
|
|
512
|
+
if (!this.alive(generation)) return;
|
|
513
|
+
if (conversation.id !== this.room) throw new Error("Conversation response belongs to a different room");
|
|
514
|
+
this.validatePage(page);
|
|
515
|
+
this.cursor = page.at(-1);
|
|
516
|
+
this.patch({ conversation, messages: this.overlay(page, revision), hasOlderMessages: page.length === this.pageSize });
|
|
517
|
+
this.mergeReads(conversation.participants.flatMap((participant) => participant.lastReadAt ? [[participant.appUserId, participant.lastReadAt]] : []));
|
|
518
|
+
this.prune(revision);
|
|
519
|
+
if (this.options.markReadOnLoad ?? true) await this.markRead();
|
|
520
|
+
} catch (cause) {
|
|
521
|
+
this.fail(cause, generation, true);
|
|
522
|
+
} finally {
|
|
523
|
+
if (this.alive(generation)) {
|
|
524
|
+
this.patch({ isInitialLoading: false, hasLoaded: true });
|
|
525
|
+
this.flushRefresh();
|
|
526
|
+
}
|
|
527
|
+
}
|
|
221
528
|
};
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
isLoadingOlder.
|
|
238
|
-
|
|
529
|
+
queueRefresh() {
|
|
530
|
+
this.refreshQueued = true;
|
|
531
|
+
this.flushRefresh();
|
|
532
|
+
}
|
|
533
|
+
flushRefresh() {
|
|
534
|
+
const generation = this.generation;
|
|
535
|
+
void Promise.resolve().then(() => {
|
|
536
|
+
if (!this.alive(generation) || !this.refreshQueued || this.state.isInitialLoading || this.state.isLoadingOlder || this.state.isReconciling) return;
|
|
537
|
+
this.refreshQueued = false;
|
|
538
|
+
void this.refresh();
|
|
539
|
+
});
|
|
540
|
+
}
|
|
541
|
+
/** Re-fetch the entire viewed range atomically; a first-page-only refresh loses history. */
|
|
542
|
+
refresh = async () => {
|
|
543
|
+
if (!this.alive()) return;
|
|
544
|
+
if (this.state.isInitialLoading || this.state.isLoadingOlder || this.state.isReconciling) {
|
|
545
|
+
this.refreshQueued = true;
|
|
546
|
+
return;
|
|
547
|
+
}
|
|
548
|
+
if (!this.state.hasLoaded || !this.subscriptions.length) return this.loadInitial();
|
|
549
|
+
const generation = this.generation;
|
|
550
|
+
const revision = this.revision;
|
|
551
|
+
const observed = this.state.messages.filter((message) => !isConvoKitPendingMessage(message)).concat([...this.changes.values()].filter((change) => change.insert).map((change) => change.message));
|
|
552
|
+
const boundary = observed.reduce((oldest, message) => !oldest || compare(message, oldest) < 0 ? message : oldest, this.cursor);
|
|
553
|
+
const previouslyExhausted = !this.state.hasOlderMessages;
|
|
554
|
+
this.patch({ isReconciling: true, error: null });
|
|
239
555
|
try {
|
|
240
|
-
const
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
]
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
556
|
+
const conversation = await this.client.getConversation(this.room);
|
|
557
|
+
if (!this.alive(generation)) return;
|
|
558
|
+
if (conversation.id !== this.room) throw new Error("Conversation response belongs to a different room");
|
|
559
|
+
const rows = [];
|
|
560
|
+
let before;
|
|
561
|
+
let hasOlder = true;
|
|
562
|
+
while (this.alive(generation)) {
|
|
563
|
+
const page = await this.fetchPage(before);
|
|
564
|
+
if (!this.alive(generation)) return;
|
|
565
|
+
this.validatePage(page, before);
|
|
566
|
+
rows.push(...page);
|
|
567
|
+
before = page.at(-1) ?? before;
|
|
568
|
+
hasOlder = page.length === this.pageSize;
|
|
569
|
+
if (!hasOlder || !boundary || !previouslyExhausted && before && compare(before, boundary) < 0) break;
|
|
570
|
+
}
|
|
571
|
+
this.cursor = before;
|
|
572
|
+
const reconciled = this.overlay(rows, revision);
|
|
573
|
+
const survivingIds = new Set(reconciled.map((message) => message.id));
|
|
574
|
+
const known = this.state.messages.filter((message) => !isConvoKitPendingMessage(message)).map((message) => message.id).concat([...this.changes].filter(([, change]) => change.insert && change.revision <= revision).map(([id]) => id));
|
|
575
|
+
for (const id of known) if (!survivingIds.has(id)) {
|
|
576
|
+
this.deleted.add(id);
|
|
577
|
+
this.changes.delete(id);
|
|
578
|
+
this.hydrations.delete(id);
|
|
579
|
+
this.hydrationPool.queued.delete(id);
|
|
580
|
+
}
|
|
581
|
+
this.patch({ conversation, messages: reconciled, hasOlderMessages: hasOlder });
|
|
582
|
+
this.mergeReads(conversation.participants.flatMap((participant) => participant.lastReadAt ? [[participant.appUserId, participant.lastReadAt]] : []));
|
|
583
|
+
this.prune(revision);
|
|
250
584
|
} catch (cause) {
|
|
251
|
-
|
|
585
|
+
this.fail(cause, generation, true);
|
|
252
586
|
} finally {
|
|
253
|
-
if (
|
|
254
|
-
|
|
255
|
-
|
|
587
|
+
if (this.alive(generation)) {
|
|
588
|
+
this.patch({ isReconciling: false });
|
|
589
|
+
this.flushRefresh();
|
|
256
590
|
}
|
|
257
591
|
}
|
|
258
592
|
};
|
|
259
|
-
|
|
260
|
-
if (isInitialLoading.
|
|
261
|
-
const
|
|
262
|
-
const
|
|
263
|
-
const
|
|
264
|
-
|
|
265
|
-
error.value = null;
|
|
593
|
+
loadOlderMessages = async () => {
|
|
594
|
+
if (!this.alive() || !this.state.hasLoaded || this.state.isInitialLoading || this.state.isLoadingOlder || this.state.isReconciling || !this.state.hasOlderMessages) return;
|
|
595
|
+
const generation = this.generation;
|
|
596
|
+
const revision = this.revision;
|
|
597
|
+
const cursor = this.cursor;
|
|
598
|
+
this.patch({ isLoadingOlder: true, error: null });
|
|
266
599
|
try {
|
|
267
|
-
const page = await
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
600
|
+
const page = await this.fetchPage(cursor);
|
|
601
|
+
if (!this.alive(generation)) return;
|
|
602
|
+
this.validatePage(page, cursor);
|
|
603
|
+
this.cursor = page.at(-1) ?? cursor;
|
|
604
|
+
this.patch({
|
|
605
|
+
messages: this.overlay(mergeMessages(page, this.state.messages), revision),
|
|
606
|
+
hasOlderMessages: page.length === this.pageSize
|
|
271
607
|
});
|
|
272
|
-
if (disposed || activeGeneration !== generation) return;
|
|
273
|
-
messages.value = mergeMessages(messages.value, page);
|
|
274
|
-
hasOlderMessages.value = page.length === messagePageSize;
|
|
275
608
|
} catch (cause) {
|
|
276
|
-
|
|
609
|
+
this.fail(cause, generation, true);
|
|
277
610
|
} finally {
|
|
278
|
-
if (
|
|
611
|
+
if (this.alive(generation)) {
|
|
612
|
+
this.patch({ isLoadingOlder: false });
|
|
613
|
+
this.flushRefresh();
|
|
614
|
+
}
|
|
279
615
|
}
|
|
280
616
|
};
|
|
281
|
-
|
|
282
|
-
if (
|
|
283
|
-
|
|
284
|
-
void updateTyping(false);
|
|
285
|
-
}, typingTimeoutMs);
|
|
286
|
-
if (sentTyping === nextTyping) return;
|
|
287
|
-
sentTyping = nextTyping;
|
|
617
|
+
markRead = async () => {
|
|
618
|
+
if (!this.alive()) return;
|
|
619
|
+
const generation = this.generation;
|
|
288
620
|
try {
|
|
289
|
-
await
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
621
|
+
await this.client.markConversationRead(this.room);
|
|
622
|
+
} catch (cause) {
|
|
623
|
+
this.fail(cause, generation);
|
|
624
|
+
}
|
|
625
|
+
};
|
|
626
|
+
updateTyping = async (isTyping) => {
|
|
627
|
+
if (!this.alive()) return;
|
|
628
|
+
const generation = this.generation;
|
|
629
|
+
clearTimeout(this.ownTypingTimer);
|
|
630
|
+
if (isTyping) this.ownTypingTimer = setTimeout(() => {
|
|
631
|
+
if (this.alive(generation)) void this.updateTyping(false);
|
|
632
|
+
}, this.typingTimeout);
|
|
633
|
+
const now = performance.now();
|
|
634
|
+
const renew = isTyping && now - this.lastTypingSentAt >= Math.max(1, this.typingTimeout / 2);
|
|
635
|
+
if (this.sentTyping === isTyping && !renew) return;
|
|
636
|
+
const revision = ++this.typingRevision;
|
|
637
|
+
this.sentTyping = isTyping;
|
|
638
|
+
this.lastTypingSentAt = isTyping ? now : -Infinity;
|
|
639
|
+
try {
|
|
640
|
+
await this.client.sendTyping({ conversationId: this.room, isTyping });
|
|
293
641
|
} catch (cause) {
|
|
294
|
-
if (
|
|
642
|
+
if (this.alive(generation) && revision === this.typingRevision) {
|
|
643
|
+
this.sentTyping = false;
|
|
644
|
+
this.fail(cause, generation);
|
|
645
|
+
}
|
|
295
646
|
}
|
|
296
647
|
};
|
|
297
|
-
|
|
298
|
-
const
|
|
299
|
-
if (!
|
|
300
|
-
|
|
301
|
-
const
|
|
302
|
-
|
|
648
|
+
sendMessage = async ({ text, media }) => {
|
|
649
|
+
const normalized = text?.trim();
|
|
650
|
+
if (!this.alive() || this.state.isSending || !normalized && !media?.length) return null;
|
|
651
|
+
const generation = this.generation;
|
|
652
|
+
const revision = this.revision;
|
|
653
|
+
this.sendRevision = revision;
|
|
654
|
+
const clientMessageId = createClientMessageId();
|
|
655
|
+
const pendingId = `convokit-pending-${clientMessageId}`;
|
|
656
|
+
const pending = {
|
|
303
657
|
id: pendingId,
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
658
|
+
clientMessageId,
|
|
659
|
+
conversationId: this.room,
|
|
660
|
+
senderId: this.user,
|
|
661
|
+
text: normalized || null,
|
|
307
662
|
media: media ?? [],
|
|
308
663
|
createdAt: /* @__PURE__ */ new Date(),
|
|
309
664
|
updatedAt: null
|
|
310
665
|
};
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
error.value = null;
|
|
315
|
-
const activeGeneration = generation;
|
|
666
|
+
const send = { pending };
|
|
667
|
+
this.activeSend = send;
|
|
668
|
+
this.patch({ messages: mergeMessages(this.state.messages, [pending]), isSending: true, error: null });
|
|
316
669
|
try {
|
|
317
|
-
const message = await
|
|
318
|
-
conversationId:
|
|
319
|
-
|
|
670
|
+
const message = await this.client.sendMessage({
|
|
671
|
+
conversationId: this.room,
|
|
672
|
+
clientMessageId,
|
|
673
|
+
...normalized ? { text: normalized } : {},
|
|
320
674
|
...media?.length ? { media } : {}
|
|
321
675
|
});
|
|
322
|
-
|
|
323
|
-
if (!
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
676
|
+
if (!this.alive(generation)) return null;
|
|
677
|
+
if (!this.validMessage(message) || message.senderId !== this.user) throw new Error("Send response belongs to a different room or sender");
|
|
678
|
+
if (message.clientMessageId && message.clientMessageId !== clientMessageId) throw new Error("Send response belongs to a different send");
|
|
679
|
+
const live = this.changes.get(message.id);
|
|
680
|
+
const existing = this.state.messages.find((item) => item.id === message.id);
|
|
681
|
+
let latest = existing ? newest(message, existing, live?.complete !== false) : message;
|
|
682
|
+
if (live && live.revision > revision) latest = newest(latest, live.message, live.complete);
|
|
683
|
+
if (!this.deleted.has(message.id)) this.changes.set(message.id, { revision: ++this.revision, message: latest, insert: true, complete: true });
|
|
684
|
+
this.patch({ messages: mergeMessages(
|
|
685
|
+
this.state.messages.filter((item) => item.id !== pendingId),
|
|
686
|
+
this.deleted.has(message.id) ? [] : [latest]
|
|
687
|
+
) });
|
|
688
|
+
void this.updateTyping(false);
|
|
689
|
+
return this.alive(generation) ? latest : null;
|
|
331
690
|
} catch (cause) {
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
691
|
+
if (this.alive(generation)) {
|
|
692
|
+
this.patch({ messages: this.state.messages.filter((item) => item.id !== pendingId) });
|
|
693
|
+
if (send.confirmed) {
|
|
694
|
+
void this.updateTyping(false);
|
|
695
|
+
return send.confirmed;
|
|
696
|
+
}
|
|
697
|
+
this.fail(cause, generation);
|
|
336
698
|
}
|
|
337
699
|
return null;
|
|
338
700
|
} finally {
|
|
339
|
-
if (
|
|
701
|
+
if (this.alive(generation)) {
|
|
702
|
+
this.sendRevision = void 0;
|
|
703
|
+
this.activeSend = void 0;
|
|
704
|
+
this.patch({ isSending: false });
|
|
705
|
+
}
|
|
340
706
|
}
|
|
341
707
|
};
|
|
708
|
+
readerIdsFor = (message) => readerIdsFor(message, this.state.readAtByUserId);
|
|
709
|
+
};
|
|
710
|
+
|
|
711
|
+
// src/composables/use-conversation.ts
|
|
712
|
+
function useConversation(options) {
|
|
713
|
+
const createStore = () => new ConversationStore({
|
|
714
|
+
...options,
|
|
715
|
+
client: toValue(options.client),
|
|
716
|
+
conversationId: toValue(options.conversationId)
|
|
717
|
+
});
|
|
718
|
+
let store = createStore();
|
|
719
|
+
const snapshot = shallowRef(store.getSnapshot());
|
|
720
|
+
let unsubscribe;
|
|
721
|
+
const stop = watch(
|
|
722
|
+
() => [toValue(options.client), toValue(options.client).sessionIdentity, toValue(options.conversationId)],
|
|
723
|
+
() => {
|
|
724
|
+
store.dispose();
|
|
725
|
+
unsubscribe?.();
|
|
726
|
+
store = createStore();
|
|
727
|
+
snapshot.value = store.getSnapshot();
|
|
728
|
+
unsubscribe = store.subscribe(() => {
|
|
729
|
+
snapshot.value = store.getSnapshot();
|
|
730
|
+
});
|
|
731
|
+
store.start(options.autoLoad ?? true);
|
|
732
|
+
},
|
|
733
|
+
{ immediate: true, flush: "sync" }
|
|
734
|
+
);
|
|
735
|
+
const field = (key) => computed(() => snapshot.value[key]);
|
|
342
736
|
const dispose = async () => {
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
await toValue(options.client).sendTyping({
|
|
348
|
-
conversationId: toValue(options.conversationId),
|
|
349
|
-
isTyping: false
|
|
350
|
-
}).catch(() => void 0);
|
|
351
|
-
}
|
|
352
|
-
await unsubscribe();
|
|
737
|
+
stop();
|
|
738
|
+
store.dispose();
|
|
739
|
+
unsubscribe?.();
|
|
740
|
+
unsubscribe = void 0;
|
|
353
741
|
};
|
|
354
|
-
if (options.autoLoad ?? true) {
|
|
355
|
-
watch(
|
|
356
|
-
() => [toValue(options.client), toValue(options.conversationId)],
|
|
357
|
-
() => {
|
|
358
|
-
disposed = false;
|
|
359
|
-
sentTyping = false;
|
|
360
|
-
void loadInitial();
|
|
361
|
-
},
|
|
362
|
-
{ immediate: true }
|
|
363
|
-
);
|
|
364
|
-
}
|
|
365
742
|
if (getCurrentScope()) onScopeDispose(() => {
|
|
366
743
|
void dispose();
|
|
367
744
|
});
|
|
368
745
|
return {
|
|
369
|
-
conversation,
|
|
370
|
-
messages,
|
|
371
|
-
typingUserIds,
|
|
372
|
-
readAtByUserId,
|
|
373
|
-
isInitialLoading,
|
|
374
|
-
isLoadingOlder,
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
746
|
+
conversation: field("conversation"),
|
|
747
|
+
messages: field("messages"),
|
|
748
|
+
typingUserIds: field("typingUserIds"),
|
|
749
|
+
readAtByUserId: field("readAtByUserId"),
|
|
750
|
+
isInitialLoading: field("isInitialLoading"),
|
|
751
|
+
isLoadingOlder: field("isLoadingOlder"),
|
|
752
|
+
isReconciling: field("isReconciling"),
|
|
753
|
+
isSending: field("isSending"),
|
|
754
|
+
hasOlderMessages: field("hasOlderMessages"),
|
|
755
|
+
hasLoaded: field("hasLoaded"),
|
|
756
|
+
error: field("error"),
|
|
757
|
+
currentUserId: field("currentUserId"),
|
|
758
|
+
readerIdsFor: (message) => store.readerIdsFor(message),
|
|
759
|
+
loadInitial: () => store.loadInitial(),
|
|
760
|
+
refresh: () => store.refresh(),
|
|
761
|
+
loadOlderMessages: () => store.loadOlderMessages(),
|
|
762
|
+
sendMessage: (input) => store.sendMessage(input),
|
|
763
|
+
markRead: () => store.markRead(),
|
|
764
|
+
updateTyping: (isTyping) => store.updateTyping(isTyping),
|
|
387
765
|
dispose
|
|
388
766
|
};
|
|
389
767
|
}
|
|
@@ -405,7 +783,7 @@ import {
|
|
|
405
783
|
defineComponent as defineComponent2,
|
|
406
784
|
h as h2,
|
|
407
785
|
nextTick,
|
|
408
|
-
ref
|
|
786
|
+
ref,
|
|
409
787
|
watch as watch2
|
|
410
788
|
} from "vue";
|
|
411
789
|
var appearanceProps = {
|
|
@@ -471,7 +849,7 @@ var MessageListView = defineComponent2({
|
|
|
471
849
|
},
|
|
472
850
|
emits: ["load-older", "attachment-click"],
|
|
473
851
|
setup(props, { attrs, emit, slots }) {
|
|
474
|
-
const internalElement =
|
|
852
|
+
const internalElement = ref(null);
|
|
475
853
|
let requestInFlight = false;
|
|
476
854
|
let lastRequestedLength = null;
|
|
477
855
|
let previousMessageCount = 0;
|
|
@@ -681,8 +1059,8 @@ var ConversationView = defineComponent3({
|
|
|
681
1059
|
"update:modelValue"
|
|
682
1060
|
],
|
|
683
1061
|
setup(props, { attrs, emit, slots }) {
|
|
684
|
-
const internalDraft =
|
|
685
|
-
const submitting =
|
|
1062
|
+
const internalDraft = ref2(props.defaultDraft);
|
|
1063
|
+
const submitting = ref2(false);
|
|
686
1064
|
const appearance = () => ({
|
|
687
1065
|
density: props.density,
|
|
688
1066
|
unstyled: props.unstyled,
|
|
@@ -1010,119 +1388,271 @@ import { ChevronRight, Inbox, LoaderCircle as LoaderCircle3, RefreshCw as Refres
|
|
|
1010
1388
|
import {
|
|
1011
1389
|
defineComponent as defineComponent4,
|
|
1012
1390
|
h as h4,
|
|
1013
|
-
ref as
|
|
1391
|
+
ref as ref3,
|
|
1014
1392
|
watchEffect as watchEffect2
|
|
1015
1393
|
} from "vue";
|
|
1016
1394
|
|
|
1017
1395
|
// src/composables/use-conversation-list.ts
|
|
1018
|
-
import {
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1396
|
+
import { computed as computed3, getCurrentScope as getCurrentScope2, onScopeDispose as onScopeDispose2, shallowRef as shallowRef2, toValue as toValue2, watch as watch3 } from "vue";
|
|
1397
|
+
|
|
1398
|
+
// src/conversation-list-store.ts
|
|
1399
|
+
function blank2(filter) {
|
|
1400
|
+
return { conversations: [], filter, isInitialLoading: false, isLoadingMore: false, hasMore: true, hasLoaded: false, error: null };
|
|
1401
|
+
}
|
|
1402
|
+
var ConversationListStore = class {
|
|
1403
|
+
constructor(options) {
|
|
1404
|
+
this.options = options;
|
|
1405
|
+
this.owner = options.client.sessionIdentity;
|
|
1406
|
+
this.pageSize = options.pageSize ?? 30;
|
|
1407
|
+
if (!Number.isInteger(this.pageSize) || this.pageSize < 1 || this.pageSize > 100) {
|
|
1408
|
+
throw new RangeError("pageSize must be an integer between 1 and 100");
|
|
1409
|
+
}
|
|
1410
|
+
this.state = blank2(options.initialFilter ?? {});
|
|
1411
|
+
}
|
|
1412
|
+
options;
|
|
1413
|
+
owner;
|
|
1414
|
+
pageSize;
|
|
1415
|
+
state;
|
|
1416
|
+
source = [];
|
|
1417
|
+
offset = 0;
|
|
1418
|
+
generation = 0;
|
|
1419
|
+
lifecycleGeneration = 0;
|
|
1420
|
+
disposed = true;
|
|
1421
|
+
lifecycle;
|
|
1422
|
+
inbox;
|
|
1423
|
+
refreshQueued = false;
|
|
1424
|
+
refreshing = false;
|
|
1425
|
+
listeners = /* @__PURE__ */ new Set();
|
|
1426
|
+
getSnapshot = () => this.state;
|
|
1427
|
+
subscribe = (listener) => {
|
|
1428
|
+
this.listeners.add(listener);
|
|
1429
|
+
return () => {
|
|
1430
|
+
this.listeners.delete(listener);
|
|
1431
|
+
};
|
|
1432
|
+
};
|
|
1433
|
+
patch(patch) {
|
|
1434
|
+
this.state = { ...this.state, ...patch };
|
|
1435
|
+
for (const listener of this.listeners) listener();
|
|
1436
|
+
}
|
|
1437
|
+
alive(generation = this.generation) {
|
|
1438
|
+
return !this.disposed && generation === this.generation && this.owner !== null && this.options.client.sessionIdentity === this.owner;
|
|
1439
|
+
}
|
|
1440
|
+
start = (autoLoad = true) => {
|
|
1441
|
+
if (!this.owner || this.options.client.sessionIdentity !== this.owner) return;
|
|
1442
|
+
if (!this.disposed) return;
|
|
1443
|
+
this.disposed = false;
|
|
1444
|
+
const lifecycleGeneration = ++this.lifecycleGeneration;
|
|
1445
|
+
try {
|
|
1446
|
+
const subscription = this.options.client.onConnectionEvent({
|
|
1447
|
+
onEvent: () => {
|
|
1448
|
+
},
|
|
1449
|
+
onSessionEnded: () => {
|
|
1450
|
+
if (!this.disposed && lifecycleGeneration === this.lifecycleGeneration) this.dispose();
|
|
1451
|
+
}
|
|
1049
1452
|
});
|
|
1050
|
-
if (
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1453
|
+
if (this.alive()) this.lifecycle = subscription;
|
|
1454
|
+
else void subscription.unsubscribe().catch(() => void 0);
|
|
1455
|
+
if (!this.alive()) return;
|
|
1456
|
+
const inbox = this.options.client.onInboxChanged(() => {
|
|
1457
|
+
if (this.alive() && lifecycleGeneration === this.lifecycleGeneration) this.queueRefresh();
|
|
1458
|
+
}, (cause) => {
|
|
1459
|
+
if (this.alive() && lifecycleGeneration === this.lifecycleGeneration) {
|
|
1460
|
+
this.patch({ error: cause });
|
|
1461
|
+
this.queueRefresh();
|
|
1462
|
+
}
|
|
1463
|
+
});
|
|
1464
|
+
if (this.alive()) this.inbox = inbox;
|
|
1465
|
+
else void inbox.unsubscribe().catch(() => void 0);
|
|
1466
|
+
if (autoLoad) void this.loadInitial();
|
|
1467
|
+
} catch (cause) {
|
|
1468
|
+
if (this.alive()) this.patch({ error: cause });
|
|
1055
1469
|
}
|
|
1056
1470
|
};
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1471
|
+
dispose = () => {
|
|
1472
|
+
this.disposed = true;
|
|
1473
|
+
this.generation++;
|
|
1474
|
+
this.lifecycleGeneration++;
|
|
1475
|
+
const subscription = this.lifecycle;
|
|
1476
|
+
this.lifecycle = void 0;
|
|
1477
|
+
if (subscription) void subscription.unsubscribe().catch(() => void 0);
|
|
1478
|
+
if (this.inbox) void this.inbox.unsubscribe().catch(() => void 0);
|
|
1479
|
+
this.inbox = void 0;
|
|
1480
|
+
this.refreshQueued = false;
|
|
1481
|
+
this.refreshing = false;
|
|
1482
|
+
this.source = [];
|
|
1483
|
+
this.offset = 0;
|
|
1484
|
+
this.patch(blank2(this.state.filter));
|
|
1485
|
+
};
|
|
1486
|
+
fail(cause, generation) {
|
|
1487
|
+
if (!this.alive(generation)) return;
|
|
1488
|
+
const status = typeof cause === "object" && cause !== null && "status" in cause ? cause.status : void 0;
|
|
1489
|
+
if (status === 401 || status === 403 || status === 404) {
|
|
1490
|
+
this.source = [];
|
|
1491
|
+
this.offset = 0;
|
|
1492
|
+
this.patch({ conversations: [], hasMore: false });
|
|
1493
|
+
}
|
|
1494
|
+
this.patch({ error: cause });
|
|
1495
|
+
}
|
|
1496
|
+
async loadUntilVisible(generation, filter) {
|
|
1497
|
+
const visibleBefore = applyConversationFilter(this.source, filter).length;
|
|
1498
|
+
while (this.alive(generation)) {
|
|
1499
|
+
const request = { limit: this.pageSize, offset: this.offset, filter };
|
|
1500
|
+
const page = this.options.pageLoader ? await this.options.pageLoader(request) : await this.options.client.getConversations({ limit: this.pageSize, offset: this.offset, archived: filter.archived ?? false });
|
|
1501
|
+
if (!this.alive(generation)) return;
|
|
1502
|
+
if (page.length > this.pageSize || page.some((conversation) => !conversation.id.trim())) {
|
|
1503
|
+
throw new Error("Invalid conversation page");
|
|
1504
|
+
}
|
|
1505
|
+
const merged = mergeConversations(this.source, page);
|
|
1506
|
+
if (page.length === this.pageSize && merged.length === this.source.length) {
|
|
1507
|
+
throw new Error("Conversation pagination did not advance");
|
|
1508
|
+
}
|
|
1509
|
+
this.offset += page.length;
|
|
1510
|
+
this.source = merged;
|
|
1511
|
+
const visible = applyConversationFilter(merged, filter);
|
|
1512
|
+
const hasMore = page.length === this.pageSize;
|
|
1513
|
+
this.patch({ conversations: visible, hasMore });
|
|
1514
|
+
if (!hasMore || visible.length > visibleBefore) return;
|
|
1515
|
+
}
|
|
1516
|
+
}
|
|
1517
|
+
loadInitial = async () => {
|
|
1518
|
+
if (!this.alive()) return;
|
|
1519
|
+
const generation = ++this.generation;
|
|
1520
|
+
this.refreshing = false;
|
|
1521
|
+
this.source = [];
|
|
1522
|
+
this.offset = 0;
|
|
1523
|
+
this.patch({ ...blank2(this.state.filter), isInitialLoading: true });
|
|
1065
1524
|
try {
|
|
1066
|
-
await loadUntilVisible(
|
|
1525
|
+
await this.loadUntilVisible(generation, this.state.filter);
|
|
1067
1526
|
} catch (cause) {
|
|
1068
|
-
|
|
1527
|
+
this.fail(cause, generation);
|
|
1069
1528
|
} finally {
|
|
1070
|
-
if (
|
|
1071
|
-
|
|
1072
|
-
|
|
1529
|
+
if (this.alive(generation)) {
|
|
1530
|
+
this.patch({ isInitialLoading: false, hasLoaded: true });
|
|
1531
|
+
this.flushRefresh();
|
|
1073
1532
|
}
|
|
1074
1533
|
}
|
|
1075
1534
|
};
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1535
|
+
queueRefresh() {
|
|
1536
|
+
this.refreshQueued = true;
|
|
1537
|
+
this.flushRefresh();
|
|
1538
|
+
}
|
|
1539
|
+
flushRefresh() {
|
|
1540
|
+
const lifecycle = this.lifecycleGeneration;
|
|
1541
|
+
void Promise.resolve().then(() => {
|
|
1542
|
+
if (!this.alive() || lifecycle !== this.lifecycleGeneration || !this.refreshQueued || this.refreshing || this.state.isInitialLoading || this.state.isLoadingMore) return;
|
|
1543
|
+
this.refreshQueued = false;
|
|
1544
|
+
void this.refresh();
|
|
1545
|
+
});
|
|
1546
|
+
}
|
|
1547
|
+
/** Replace the loaded window atomically, retaining filters and rows during transient failures. */
|
|
1548
|
+
refresh = async () => {
|
|
1549
|
+
if (!this.alive()) return;
|
|
1550
|
+
if (this.refreshing || this.state.isInitialLoading || this.state.isLoadingMore) {
|
|
1551
|
+
this.refreshQueued = true;
|
|
1552
|
+
return;
|
|
1553
|
+
}
|
|
1554
|
+
if (!this.state.hasLoaded) return this.loadInitial();
|
|
1555
|
+
const generation = this.generation;
|
|
1556
|
+
const filter = this.state.filter;
|
|
1557
|
+
const target = Math.max(this.pageSize, this.offset);
|
|
1558
|
+
const compare2 = (a, b) => a.createdAt.getTime() - b.createdAt.getTime() || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
|
|
1559
|
+
const boundary = this.options.pageLoader ? void 0 : this.source.reduce((oldest, row) => !oldest || compare2(row, oldest) < 0 ? row : oldest, void 0);
|
|
1560
|
+
this.refreshing = true;
|
|
1561
|
+
this.patch({ error: null });
|
|
1081
1562
|
try {
|
|
1082
|
-
|
|
1563
|
+
let rows = [], offset = 0, hasMore = true;
|
|
1564
|
+
while (this.alive(generation)) {
|
|
1565
|
+
const page = this.options.pageLoader ? await this.options.pageLoader({ limit: this.pageSize, offset, filter }) : await this.options.client.getConversations({ limit: this.pageSize, offset, archived: filter.archived ?? false });
|
|
1566
|
+
if (!this.alive(generation)) return;
|
|
1567
|
+
if (page.length > this.pageSize || page.some((row) => !row.id.trim())) throw new Error("Invalid conversation page");
|
|
1568
|
+
const merged = mergeConversations(rows, page);
|
|
1569
|
+
if (page.length === this.pageSize && merged.length === rows.length) throw new Error("Conversation pagination did not advance");
|
|
1570
|
+
rows = merged;
|
|
1571
|
+
offset += page.length;
|
|
1572
|
+
hasMore = page.length === this.pageSize;
|
|
1573
|
+
if (!hasMore || offset >= target && applyConversationFilter(rows, this.state.filter).length > 0 && (!boundary || page.some((row) => compare2(row, boundary) <= 0))) break;
|
|
1574
|
+
}
|
|
1575
|
+
if (!this.alive(generation)) return;
|
|
1576
|
+
this.source = rows;
|
|
1577
|
+
this.offset = offset;
|
|
1578
|
+
this.patch({ conversations: applyConversationFilter(rows, this.state.filter), hasMore });
|
|
1083
1579
|
} catch (cause) {
|
|
1084
|
-
|
|
1580
|
+
this.fail(cause, generation);
|
|
1085
1581
|
} finally {
|
|
1086
|
-
if (
|
|
1582
|
+
if (this.alive(generation)) {
|
|
1583
|
+
this.refreshing = false;
|
|
1584
|
+
this.flushRefresh();
|
|
1585
|
+
}
|
|
1087
1586
|
}
|
|
1088
1587
|
};
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
await
|
|
1095
|
-
}
|
|
1096
|
-
|
|
1588
|
+
loadMore = async () => {
|
|
1589
|
+
if (!this.alive() || this.refreshing || this.state.isInitialLoading || this.state.isLoadingMore || !this.state.hasMore) return;
|
|
1590
|
+
const generation = this.generation;
|
|
1591
|
+
this.patch({ isLoadingMore: true, error: null });
|
|
1592
|
+
try {
|
|
1593
|
+
await this.loadUntilVisible(generation, this.state.filter);
|
|
1594
|
+
} catch (cause) {
|
|
1595
|
+
this.fail(cause, generation);
|
|
1596
|
+
} finally {
|
|
1597
|
+
if (this.alive(generation)) {
|
|
1598
|
+
this.patch({ isLoadingMore: false });
|
|
1599
|
+
this.flushRefresh();
|
|
1600
|
+
}
|
|
1097
1601
|
}
|
|
1098
1602
|
};
|
|
1099
|
-
|
|
1603
|
+
setFilter = async (filter) => {
|
|
1604
|
+
if (!this.alive()) return;
|
|
1605
|
+
const reload = this.options.pageLoader || !this.state.hasLoaded || this.state.isInitialLoading || this.state.isLoadingMore || (filter.archived ?? false) !== (this.state.filter.archived ?? false);
|
|
1606
|
+
this.patch({ filter, conversations: applyConversationFilter(this.source, filter), error: null });
|
|
1607
|
+
if (reload) await this.loadInitial();
|
|
1608
|
+
else if (!this.state.conversations.length && this.state.hasMore) await this.loadMore();
|
|
1609
|
+
};
|
|
1610
|
+
setQuery = (query) => this.setFilter({ ...this.state.filter, query });
|
|
1611
|
+
};
|
|
1612
|
+
|
|
1613
|
+
// src/composables/use-conversation-list.ts
|
|
1614
|
+
function useConversationList(options) {
|
|
1615
|
+
const createStore = () => new ConversationListStore({ ...options, client: toValue2(options.client) });
|
|
1616
|
+
let store = createStore();
|
|
1617
|
+
const snapshot = shallowRef2(store.getSnapshot());
|
|
1618
|
+
let unsubscribe;
|
|
1619
|
+
const stop = watch3(
|
|
1620
|
+
() => [toValue2(options.client), toValue2(options.client).sessionIdentity],
|
|
1621
|
+
() => {
|
|
1622
|
+
store.dispose();
|
|
1623
|
+
unsubscribe?.();
|
|
1624
|
+
store = createStore();
|
|
1625
|
+
snapshot.value = store.getSnapshot();
|
|
1626
|
+
unsubscribe = store.subscribe(() => {
|
|
1627
|
+
snapshot.value = store.getSnapshot();
|
|
1628
|
+
});
|
|
1629
|
+
store.start(options.autoLoad ?? true);
|
|
1630
|
+
},
|
|
1631
|
+
{ immediate: true, flush: "sync" }
|
|
1632
|
+
);
|
|
1633
|
+
const field = (key) => computed3(() => snapshot.value[key]);
|
|
1100
1634
|
const dispose = async () => {
|
|
1101
|
-
|
|
1102
|
-
|
|
1635
|
+
stop();
|
|
1636
|
+
store.dispose();
|
|
1637
|
+
unsubscribe?.();
|
|
1638
|
+
unsubscribe = void 0;
|
|
1103
1639
|
};
|
|
1104
|
-
if (options.autoLoad ?? true) {
|
|
1105
|
-
watch3(() => toValue2(options.client), () => {
|
|
1106
|
-
disposed = false;
|
|
1107
|
-
void loadInitialFor(filter.value);
|
|
1108
|
-
}, { immediate: true });
|
|
1109
|
-
}
|
|
1110
1640
|
if (getCurrentScope2()) onScopeDispose2(() => {
|
|
1111
1641
|
void dispose();
|
|
1112
1642
|
});
|
|
1113
1643
|
return {
|
|
1114
|
-
conversations,
|
|
1115
|
-
filter,
|
|
1116
|
-
isInitialLoading,
|
|
1117
|
-
isLoadingMore,
|
|
1118
|
-
hasMore,
|
|
1119
|
-
hasLoaded,
|
|
1120
|
-
error,
|
|
1121
|
-
loadInitial:
|
|
1122
|
-
refresh:
|
|
1123
|
-
loadMore,
|
|
1124
|
-
setFilter,
|
|
1125
|
-
setQuery,
|
|
1644
|
+
conversations: field("conversations"),
|
|
1645
|
+
filter: field("filter"),
|
|
1646
|
+
isInitialLoading: field("isInitialLoading"),
|
|
1647
|
+
isLoadingMore: field("isLoadingMore"),
|
|
1648
|
+
hasMore: field("hasMore"),
|
|
1649
|
+
hasLoaded: field("hasLoaded"),
|
|
1650
|
+
error: field("error"),
|
|
1651
|
+
loadInitial: () => store.loadInitial(),
|
|
1652
|
+
refresh: () => store.refresh(),
|
|
1653
|
+
loadMore: () => store.loadMore(),
|
|
1654
|
+
setFilter: (filter) => store.setFilter(filter),
|
|
1655
|
+
setQuery: (query) => store.setQuery(query),
|
|
1126
1656
|
dispose
|
|
1127
1657
|
};
|
|
1128
1658
|
}
|
|
@@ -1159,7 +1689,7 @@ var ConversationListView = defineComponent4({
|
|
|
1159
1689
|
"load-more": () => true
|
|
1160
1690
|
},
|
|
1161
1691
|
setup(props, { attrs, emit, slots }) {
|
|
1162
|
-
const internalElement =
|
|
1692
|
+
const internalElement = ref3(null);
|
|
1163
1693
|
let requestInFlight = false;
|
|
1164
1694
|
let lastRequestedLength = null;
|
|
1165
1695
|
const appearance = () => ({
|