@convokitapp/vue-ui 0.2.1 → 0.3.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 +25 -0
- package/PARITY.md +9 -1
- package/README.md +59 -1
- package/dist/index.cjs +840 -400
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +35 -29
- package/dist/index.d.ts +35 -29
- package/dist/index.js +726 -303
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
package/dist/index.js
CHANGED
|
@@ -1,12 +1,21 @@
|
|
|
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
|
+
onMessageDeleted: (conversationId, handler, onError) => client.realtime.onMessageDeleted(conversationId, {
|
|
9
|
+
onEvent: handler,
|
|
10
|
+
...onError ? { onError } : {}
|
|
11
|
+
}),
|
|
4
12
|
get currentUserId() {
|
|
5
|
-
return client.currentUserId;
|
|
13
|
+
return client.connected ? client.currentUserId : "";
|
|
6
14
|
},
|
|
7
15
|
getConversations: (options) => client.getConversations(options),
|
|
8
16
|
getConversation: (conversationId) => client.getConversation(conversationId),
|
|
9
17
|
getMessages: (options) => client.getMessages(options),
|
|
18
|
+
getMessage: (id) => client.getMessage(id),
|
|
10
19
|
sendMessage: (input) => client.sendMessage(input),
|
|
11
20
|
markConversationRead: (conversationId) => client.markConversationRead(conversationId),
|
|
12
21
|
sendTyping: (input) => client.sendTyping(input),
|
|
@@ -31,8 +40,13 @@ import { defineComponent, h } from "vue";
|
|
|
31
40
|
|
|
32
41
|
// src/utils.ts
|
|
33
42
|
import { clsx } from "clsx";
|
|
43
|
+
import { normalizeClass } from "vue";
|
|
44
|
+
var pendingMessageIdPrefix = "convokit-pending-";
|
|
45
|
+
function isConvoKitPendingMessage(message) {
|
|
46
|
+
return message.id.startsWith(pendingMessageIdPrefix);
|
|
47
|
+
}
|
|
34
48
|
function cx(...values) {
|
|
35
|
-
return clsx(values);
|
|
49
|
+
return clsx(values.map(normalizeClass));
|
|
36
50
|
}
|
|
37
51
|
function requestedParticipantIds(filter) {
|
|
38
52
|
const values = filter.participantIds ?? [];
|
|
@@ -73,11 +87,15 @@ function mergeMessages(current, incoming) {
|
|
|
73
87
|
const byId = new Map(current.map((message) => [message.id, message]));
|
|
74
88
|
for (const message of incoming) byId.set(message.id, message);
|
|
75
89
|
return [...byId.values()].sort((left, right) => {
|
|
90
|
+
const leftPending = isConvoKitPendingMessage(left);
|
|
91
|
+
const rightPending = isConvoKitPendingMessage(right);
|
|
92
|
+
if (leftPending !== rightPending) return leftPending ? 1 : -1;
|
|
76
93
|
const byTime = left.createdAt.getTime() - right.createdAt.getTime();
|
|
77
94
|
return byTime === 0 ? left.id.localeCompare(right.id) : byTime;
|
|
78
95
|
});
|
|
79
96
|
}
|
|
80
97
|
function readerIdsFor(message, readAtByUserId) {
|
|
98
|
+
if (isConvoKitPendingMessage(message)) return /* @__PURE__ */ new Set();
|
|
81
99
|
return new Set([...readAtByUserId.entries()].filter(([userId, readAt]) => userId !== message.senderId && readAt.getTime() >= message.createdAt.getTime()).map(([userId]) => userId));
|
|
82
100
|
}
|
|
83
101
|
function partClass(part, appearance, defaultClass) {
|
|
@@ -129,253 +147,582 @@ import { ArrowLeft, LoaderCircle as LoaderCircle2, Paperclip, RefreshCw, Send }
|
|
|
129
147
|
import {
|
|
130
148
|
defineComponent as defineComponent3,
|
|
131
149
|
h as h3,
|
|
132
|
-
ref as
|
|
150
|
+
ref as ref2,
|
|
133
151
|
watchEffect
|
|
134
152
|
} from "vue";
|
|
135
153
|
|
|
136
154
|
// src/composables/use-conversation.ts
|
|
137
|
-
import {
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
}
|
|
146
|
-
function
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
let typingTimer = null;
|
|
167
|
-
let subscriptions = [];
|
|
168
|
-
const pendingIds = /* @__PURE__ */ new Set();
|
|
169
|
-
let pendingSequence = 0;
|
|
170
|
-
const unsubscribe = async () => {
|
|
171
|
-
const active = subscriptions;
|
|
172
|
-
subscriptions = [];
|
|
173
|
-
await Promise.all(active.map((subscription) => subscription.unsubscribe()));
|
|
155
|
+
import { computed, getCurrentScope, onScopeDispose, shallowRef, toValue, watch } from "vue";
|
|
156
|
+
|
|
157
|
+
// src/conversation-store.ts
|
|
158
|
+
function version(message) {
|
|
159
|
+
return message.updatedAt?.getTime() ?? message.createdAt.getTime();
|
|
160
|
+
}
|
|
161
|
+
function hasContent(message) {
|
|
162
|
+
return !!message.text?.trim() || message.media.length > 0;
|
|
163
|
+
}
|
|
164
|
+
function newest(current, incoming, incomingComplete = true) {
|
|
165
|
+
return version(current) > version(incoming) || !incomingComplete && version(current) === version(incoming) ? current : incoming;
|
|
166
|
+
}
|
|
167
|
+
function compare(left, right) {
|
|
168
|
+
return left.createdAt.getTime() - right.createdAt.getTime() || (left.id < right.id ? -1 : left.id > right.id ? 1 : 0);
|
|
169
|
+
}
|
|
170
|
+
function blank(currentUserId = "") {
|
|
171
|
+
return {
|
|
172
|
+
conversation: null,
|
|
173
|
+
messages: [],
|
|
174
|
+
typingUserIds: /* @__PURE__ */ new Set(),
|
|
175
|
+
readAtByUserId: /* @__PURE__ */ new Map(),
|
|
176
|
+
isInitialLoading: false,
|
|
177
|
+
isLoadingOlder: false,
|
|
178
|
+
isReconciling: false,
|
|
179
|
+
isSending: false,
|
|
180
|
+
hasOlderMessages: true,
|
|
181
|
+
hasLoaded: false,
|
|
182
|
+
error: null,
|
|
183
|
+
currentUserId
|
|
174
184
|
};
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
185
|
+
}
|
|
186
|
+
var ConversationStore = class {
|
|
187
|
+
constructor(options) {
|
|
188
|
+
this.options = options;
|
|
189
|
+
this.client = options.client;
|
|
190
|
+
this.room = options.conversationId.trim();
|
|
191
|
+
this.pageSize = options.messagePageSize ?? 30;
|
|
192
|
+
this.typingTimeout = options.typingTimeoutMs ?? 3e3;
|
|
193
|
+
if (!this.room) throw new TypeError("conversationId is required");
|
|
194
|
+
if (!Number.isInteger(this.pageSize) || this.pageSize < 1 || this.pageSize > 100) {
|
|
195
|
+
throw new RangeError("messagePageSize must be an integer between 1 and 100");
|
|
196
|
+
}
|
|
197
|
+
if (!Number.isFinite(this.typingTimeout) || this.typingTimeout < 0) {
|
|
198
|
+
throw new RangeError("typingTimeoutMs must be non-negative");
|
|
184
199
|
}
|
|
200
|
+
this.owner = this.client.sessionIdentity;
|
|
201
|
+
this.user = this.owner ? this.client.currentUserId : "";
|
|
202
|
+
this.state = blank(this.user);
|
|
203
|
+
}
|
|
204
|
+
options;
|
|
205
|
+
client;
|
|
206
|
+
room;
|
|
207
|
+
owner;
|
|
208
|
+
user;
|
|
209
|
+
pageSize;
|
|
210
|
+
typingTimeout;
|
|
211
|
+
state;
|
|
212
|
+
listeners = /* @__PURE__ */ new Set();
|
|
213
|
+
subscriptions = [];
|
|
214
|
+
disposed = true;
|
|
215
|
+
generation = 0;
|
|
216
|
+
cursor;
|
|
217
|
+
revision = 0;
|
|
218
|
+
changes = /* @__PURE__ */ new Map();
|
|
219
|
+
hydrations = /* @__PURE__ */ new Map();
|
|
220
|
+
hydrationPool = { running: /* @__PURE__ */ new Set(), queued: /* @__PURE__ */ new Map() };
|
|
221
|
+
// Keep tombstones until an explicit reload/session change, including across refreshes.
|
|
222
|
+
deleted = /* @__PURE__ */ new Set();
|
|
223
|
+
sendRevision;
|
|
224
|
+
pendingSequence = 0;
|
|
225
|
+
refreshQueued = false;
|
|
226
|
+
typingTimers = /* @__PURE__ */ new Map();
|
|
227
|
+
ownTypingTimer;
|
|
228
|
+
sentTyping = false;
|
|
229
|
+
typingRevision = 0;
|
|
230
|
+
lastTypingSentAt = -Infinity;
|
|
231
|
+
getSnapshot = () => this.state;
|
|
232
|
+
subscribe = (listener) => {
|
|
233
|
+
this.listeners.add(listener);
|
|
234
|
+
return () => {
|
|
235
|
+
this.listeners.delete(listener);
|
|
236
|
+
};
|
|
185
237
|
};
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
238
|
+
patch(patch) {
|
|
239
|
+
this.state = { ...this.state, ...patch };
|
|
240
|
+
for (const listener of this.listeners) listener();
|
|
241
|
+
}
|
|
242
|
+
alive(generation = this.generation) {
|
|
243
|
+
return !this.disposed && generation === this.generation && this.owner !== null && this.client.sessionIdentity === this.owner;
|
|
244
|
+
}
|
|
245
|
+
start = (autoLoad = true) => {
|
|
246
|
+
if (this.owner === null || this.client.sessionIdentity !== this.owner) return;
|
|
247
|
+
this.disposed = false;
|
|
248
|
+
this.patch({ currentUserId: this.user });
|
|
249
|
+
if (autoLoad) void this.loadInitial();
|
|
250
|
+
else {
|
|
251
|
+
try {
|
|
252
|
+
this.attach(this.generation, false);
|
|
253
|
+
} catch {
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
};
|
|
257
|
+
detach() {
|
|
258
|
+
const active = this.subscriptions;
|
|
259
|
+
this.subscriptions = [];
|
|
260
|
+
for (const subscription of active) void subscription.unsubscribe().catch(() => void 0);
|
|
261
|
+
}
|
|
262
|
+
clearTyping() {
|
|
263
|
+
for (const timer of this.typingTimers.values()) clearTimeout(timer);
|
|
264
|
+
this.typingTimers.clear();
|
|
265
|
+
clearTimeout(this.ownTypingTimer);
|
|
266
|
+
this.ownTypingTimer = void 0;
|
|
267
|
+
this.sentTyping = false;
|
|
268
|
+
this.typingRevision++;
|
|
269
|
+
this.lastTypingSentAt = -Infinity;
|
|
270
|
+
this.patch({ typingUserIds: /* @__PURE__ */ new Set() });
|
|
271
|
+
}
|
|
272
|
+
clear() {
|
|
273
|
+
this.generation++;
|
|
274
|
+
this.detach();
|
|
275
|
+
this.clearTyping();
|
|
276
|
+
this.cursor = void 0;
|
|
277
|
+
this.changes.clear();
|
|
278
|
+
this.hydrations.clear();
|
|
279
|
+
this.hydrationPool.queued.clear();
|
|
280
|
+
this.deleted.clear();
|
|
281
|
+
this.sendRevision = void 0;
|
|
282
|
+
this.refreshQueued = false;
|
|
283
|
+
}
|
|
284
|
+
dispose = () => {
|
|
285
|
+
if (this.alive() && this.sentTyping) {
|
|
286
|
+
void this.client.sendTyping({ conversationId: this.room, isTyping: false }).catch(() => void 0);
|
|
287
|
+
}
|
|
288
|
+
this.disposed = true;
|
|
289
|
+
this.clear();
|
|
290
|
+
this.patch(blank());
|
|
291
|
+
};
|
|
292
|
+
fail(cause, generation, history = false) {
|
|
293
|
+
if (!this.alive(generation)) return;
|
|
294
|
+
const status = typeof cause === "object" && cause !== null && "status" in cause ? cause.status : void 0;
|
|
295
|
+
if (history && (status === 401 || status === 403 || status === 404)) {
|
|
296
|
+
this.clear();
|
|
297
|
+
this.patch({ ...blank(this.user), error: cause, hasLoaded: true, hasOlderMessages: false });
|
|
298
|
+
} else this.patch({ error: cause });
|
|
299
|
+
}
|
|
300
|
+
attach(generation, data = true) {
|
|
301
|
+
const report = (cause) => this.fail(cause, generation);
|
|
302
|
+
const add = (create) => {
|
|
303
|
+
if (!this.alive(generation)) return;
|
|
304
|
+
const subscription = create();
|
|
305
|
+
if (this.alive(generation)) this.subscriptions.push(subscription);
|
|
306
|
+
else void subscription.unsubscribe().catch(() => void 0);
|
|
189
307
|
};
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
client.
|
|
206
|
-
if (
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
}, report)
|
|
212
|
-
|
|
308
|
+
try {
|
|
309
|
+
add(() => this.client.onConnectionEvent({
|
|
310
|
+
onEvent: ({ topic, status }) => {
|
|
311
|
+
if (!data || !this.alive(generation) || topic !== `messages:${this.room}` && topic !== `conversation:${this.room}`) return;
|
|
312
|
+
if (status === "SUBSCRIBED") this.queueRefresh();
|
|
313
|
+
else this.clearTyping();
|
|
314
|
+
},
|
|
315
|
+
onSessionEnded: () => {
|
|
316
|
+
if (this.disposed || generation !== this.generation) return;
|
|
317
|
+
this.dispose();
|
|
318
|
+
},
|
|
319
|
+
onError: report
|
|
320
|
+
}));
|
|
321
|
+
if (!data) return;
|
|
322
|
+
add(() => this.client.onMessage(this.room, (event) => this.onMessage(event, generation), report));
|
|
323
|
+
add(() => this.client.onMessageDeleted(this.room, ({ id, conversationId }) => {
|
|
324
|
+
if (!this.alive(generation) || conversationId !== this.room || !id.trim()) return;
|
|
325
|
+
this.removeMessage(id);
|
|
326
|
+
}, report));
|
|
327
|
+
add(() => this.client.onReadReceipt(this.room, ({ userId, readAt }) => {
|
|
328
|
+
if (this.alive(generation)) this.mergeReads([[userId, readAt]]);
|
|
329
|
+
}, report));
|
|
330
|
+
add(() => this.client.onTyping(this.room, ({ userId, isTyping }) => {
|
|
331
|
+
if (!this.alive(generation) || !userId.trim() || userId === this.user) return;
|
|
332
|
+
clearTimeout(this.typingTimers.get(userId));
|
|
333
|
+
this.typingTimers.delete(userId);
|
|
334
|
+
const next = new Set(this.state.typingUserIds);
|
|
335
|
+
if (isTyping) {
|
|
336
|
+
next.add(userId);
|
|
337
|
+
this.typingTimers.set(userId, setTimeout(() => {
|
|
338
|
+
this.typingTimers.delete(userId);
|
|
339
|
+
if (!this.alive(generation)) return;
|
|
340
|
+
const remaining = new Set(this.state.typingUserIds);
|
|
341
|
+
remaining.delete(userId);
|
|
342
|
+
this.patch({ typingUserIds: remaining });
|
|
343
|
+
}, this.typingTimeout));
|
|
344
|
+
} else next.delete(userId);
|
|
345
|
+
this.patch({ typingUserIds: next });
|
|
346
|
+
}, report));
|
|
347
|
+
} catch (cause) {
|
|
348
|
+
this.detach();
|
|
349
|
+
this.fail(cause, generation);
|
|
350
|
+
throw cause;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
validMessage(message) {
|
|
354
|
+
return message.conversationId === this.room && !!message.id.trim() && !!message.senderId.trim() && !isConvoKitPendingMessage(message) && Number.isFinite(message.createdAt.getTime()) && Number.isFinite(version(message));
|
|
355
|
+
}
|
|
356
|
+
onMessage(event, generation) {
|
|
357
|
+
const { message, type } = event;
|
|
358
|
+
if (!this.alive(generation) || type !== "insert" && type !== "update" || !this.validMessage(message) || this.deleted.has(message.id)) return;
|
|
359
|
+
const existing = this.state.messages.find((item) => item.id === message.id);
|
|
360
|
+
const known = existing ?? this.changes.get(message.id)?.message;
|
|
361
|
+
if (known && version(message) < version(known)) return;
|
|
362
|
+
const insert = type === "insert" || this.changes.get(message.id)?.insert === true;
|
|
363
|
+
if (!existing && !insert && type === "update" && !this.state.isInitialLoading && !this.state.isLoadingOlder && !this.state.isReconciling) return;
|
|
364
|
+
const revision = ++this.revision;
|
|
365
|
+
const provisional = existing && !message.media.length ? { ...message, media: existing.media } : message;
|
|
366
|
+
this.record(provisional, insert, revision, false);
|
|
367
|
+
const job = { revision, generation, message, insert };
|
|
368
|
+
this.hydrations.set(message.id, job);
|
|
369
|
+
this.hydrationPool.queued.set(message.id, job);
|
|
370
|
+
this.drainHydration();
|
|
371
|
+
if (type === "insert" && !existing && message.senderId !== this.user && (this.options.markReadOnReceive ?? true)) {
|
|
372
|
+
void this.markRead();
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
record(message, insert, revision, complete) {
|
|
376
|
+
const existing = this.state.messages.find((item) => item.id === message.id);
|
|
377
|
+
if (existing && version(existing) > version(message)) return;
|
|
378
|
+
this.changes.set(message.id, { revision, message, insert, complete });
|
|
379
|
+
if (existing || insert && hasContent(message)) this.patch({ messages: mergeMessages(this.state.messages, [message]) });
|
|
380
|
+
}
|
|
381
|
+
currentHydration(job) {
|
|
382
|
+
return this.alive(job.generation) && !this.deleted.has(job.message.id) && this.hydrations.get(job.message.id) === job;
|
|
383
|
+
}
|
|
384
|
+
removeMessage(id) {
|
|
385
|
+
this.deleted.add(id);
|
|
386
|
+
this.changes.delete(id);
|
|
387
|
+
this.hydrations.delete(id);
|
|
388
|
+
this.hydrationPool.queued.delete(id);
|
|
389
|
+
this.patch({ messages: this.state.messages.filter((message) => message.id !== id) });
|
|
390
|
+
}
|
|
391
|
+
drainHydration() {
|
|
392
|
+
const pool = this.hydrationPool;
|
|
393
|
+
for (const [id, job] of pool.queued) {
|
|
394
|
+
if (pool.running.size >= 8) break;
|
|
395
|
+
if (pool.running.has(id)) continue;
|
|
396
|
+
pool.queued.delete(id);
|
|
397
|
+
if (!this.currentHydration(job)) continue;
|
|
398
|
+
pool.running.add(id);
|
|
399
|
+
void this.hydrate(job, pool);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
async hydrate(job, pool) {
|
|
403
|
+
const id = job.message.id;
|
|
404
|
+
try {
|
|
405
|
+
if (!this.currentHydration(job)) return;
|
|
406
|
+
const full = await this.client.getMessage(id);
|
|
407
|
+
if (!this.currentHydration(job)) return;
|
|
408
|
+
if (!this.validMessage(full) || full.id !== id || full.senderId !== job.message.senderId || version(full) < version(job.message)) {
|
|
409
|
+
throw new Error("Complete message response does not match the observed resource/revision");
|
|
410
|
+
}
|
|
411
|
+
this.record(full, job.insert, job.revision, true);
|
|
412
|
+
} catch (cause) {
|
|
413
|
+
if (!this.currentHydration(job)) return;
|
|
414
|
+
const status = typeof cause === "object" && cause !== null && "status" in cause ? cause.status : void 0;
|
|
415
|
+
if (status === 404) this.removeMessage(id);
|
|
416
|
+
this.fail(cause, job.generation);
|
|
417
|
+
this.queueRefresh();
|
|
418
|
+
} finally {
|
|
419
|
+
if (this.hydrations.get(id) === job) this.hydrations.delete(id);
|
|
420
|
+
pool.running.delete(id);
|
|
421
|
+
queueMicrotask(() => {
|
|
422
|
+
if (this.hydrationPool === pool) this.drainHydration();
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
mergeReads(entries) {
|
|
427
|
+
const next = new Map(this.state.readAtByUserId);
|
|
428
|
+
for (const [userId, readAt] of entries) {
|
|
429
|
+
if (!userId.trim() || !Number.isFinite(readAt.getTime())) continue;
|
|
430
|
+
if (readAt.getTime() > (next.get(userId)?.getTime() ?? -Infinity)) next.set(userId, readAt);
|
|
431
|
+
}
|
|
432
|
+
this.patch({ readAtByUserId: next });
|
|
433
|
+
}
|
|
434
|
+
validatePage(page, before) {
|
|
435
|
+
if (page.length > this.pageSize) throw new Error("Message page exceeds the requested limit");
|
|
436
|
+
let previous = before;
|
|
437
|
+
for (const message of page) {
|
|
438
|
+
if (!this.validMessage(message) || previous && compare(message, previous) >= 0) {
|
|
439
|
+
throw new Error("Message history must contain distinct, room-scoped rows in newest-first cursor order");
|
|
440
|
+
}
|
|
441
|
+
previous = message;
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
fetchPage(before) {
|
|
445
|
+
return this.client.getMessages({
|
|
446
|
+
conversationId: this.room,
|
|
447
|
+
limit: this.pageSize,
|
|
448
|
+
...before ? { beforeCreatedAt: before.createdAt, beforeId: before.id } : {}
|
|
449
|
+
});
|
|
450
|
+
}
|
|
451
|
+
overlay(rows, revision) {
|
|
452
|
+
const byId = new Map(rows.filter((message) => !this.deleted.has(message.id)).map((message) => [message.id, message]));
|
|
453
|
+
for (const [id, change] of this.changes) {
|
|
454
|
+
if (change.revision > revision && !this.deleted.has(id) && (change.insert || byId.has(id))) {
|
|
455
|
+
const current = byId.get(id);
|
|
456
|
+
if (current || change.complete || hasContent(change.message)) {
|
|
457
|
+
byId.set(id, current ? newest(current, change.message, change.complete) : change.message);
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
for (const message of this.state.messages) {
|
|
462
|
+
if (isConvoKitPendingMessage(message)) byId.set(message.id, message);
|
|
463
|
+
}
|
|
464
|
+
return mergeMessages([], [...byId.values()]);
|
|
465
|
+
}
|
|
466
|
+
prune(revision) {
|
|
467
|
+
const safeRevision = Math.min(revision, this.sendRevision ?? Infinity);
|
|
468
|
+
for (const [id, change] of this.changes) if (change.revision <= safeRevision) this.changes.delete(id);
|
|
469
|
+
for (const [id, job] of this.hydrations) if (job.revision <= revision) {
|
|
470
|
+
this.hydrations.delete(id);
|
|
471
|
+
this.hydrationPool.queued.delete(id);
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
loadInitial = async () => {
|
|
475
|
+
if (!this.alive()) return;
|
|
476
|
+
this.clear();
|
|
477
|
+
const generation = this.generation;
|
|
478
|
+
this.patch({ ...blank(this.user), isInitialLoading: true });
|
|
479
|
+
const revision = this.revision;
|
|
480
|
+
try {
|
|
481
|
+
this.attach(generation);
|
|
482
|
+
if (!this.alive(generation)) return;
|
|
483
|
+
const [conversation, page] = await Promise.all([this.client.getConversation(this.room), this.fetchPage()]);
|
|
484
|
+
if (!this.alive(generation)) return;
|
|
485
|
+
if (conversation.id !== this.room) throw new Error("Conversation response belongs to a different room");
|
|
486
|
+
this.validatePage(page);
|
|
487
|
+
this.cursor = page.at(-1);
|
|
488
|
+
this.patch({ conversation, messages: this.overlay(page, revision), hasOlderMessages: page.length === this.pageSize });
|
|
489
|
+
this.mergeReads(conversation.participants.flatMap((participant) => participant.lastReadAt ? [[participant.appUserId, participant.lastReadAt]] : []));
|
|
490
|
+
this.prune(revision);
|
|
491
|
+
if (this.options.markReadOnLoad ?? true) await this.markRead();
|
|
492
|
+
} catch (cause) {
|
|
493
|
+
this.fail(cause, generation, true);
|
|
494
|
+
} finally {
|
|
495
|
+
if (this.alive(generation)) {
|
|
496
|
+
this.patch({ isInitialLoading: false, hasLoaded: true });
|
|
497
|
+
this.flushRefresh();
|
|
498
|
+
}
|
|
499
|
+
}
|
|
213
500
|
};
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
isLoadingOlder.
|
|
230
|
-
|
|
501
|
+
queueRefresh() {
|
|
502
|
+
this.refreshQueued = true;
|
|
503
|
+
this.flushRefresh();
|
|
504
|
+
}
|
|
505
|
+
flushRefresh() {
|
|
506
|
+
const generation = this.generation;
|
|
507
|
+
void Promise.resolve().then(() => {
|
|
508
|
+
if (!this.alive(generation) || !this.refreshQueued || this.state.isInitialLoading || this.state.isLoadingOlder || this.state.isReconciling) return;
|
|
509
|
+
this.refreshQueued = false;
|
|
510
|
+
void this.refresh();
|
|
511
|
+
});
|
|
512
|
+
}
|
|
513
|
+
/** Re-fetch the entire viewed range atomically; a first-page-only refresh loses history. */
|
|
514
|
+
refresh = async () => {
|
|
515
|
+
if (!this.alive()) return;
|
|
516
|
+
if (this.state.isInitialLoading || this.state.isLoadingOlder || this.state.isReconciling) {
|
|
517
|
+
this.refreshQueued = true;
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
520
|
+
if (!this.state.hasLoaded || !this.subscriptions.length) return this.loadInitial();
|
|
521
|
+
const generation = this.generation;
|
|
522
|
+
const revision = this.revision;
|
|
523
|
+
const observed = this.state.messages.filter((message) => !isConvoKitPendingMessage(message)).concat([...this.changes.values()].filter((change) => change.insert).map((change) => change.message));
|
|
524
|
+
const boundary = observed.reduce((oldest, message) => !oldest || compare(message, oldest) < 0 ? message : oldest, this.cursor);
|
|
525
|
+
const previouslyExhausted = !this.state.hasOlderMessages;
|
|
526
|
+
this.patch({ isReconciling: true, error: null });
|
|
231
527
|
try {
|
|
232
|
-
const
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
]
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
528
|
+
const conversation = await this.client.getConversation(this.room);
|
|
529
|
+
if (!this.alive(generation)) return;
|
|
530
|
+
if (conversation.id !== this.room) throw new Error("Conversation response belongs to a different room");
|
|
531
|
+
const rows = [];
|
|
532
|
+
let before;
|
|
533
|
+
let hasOlder = true;
|
|
534
|
+
while (this.alive(generation)) {
|
|
535
|
+
const page = await this.fetchPage(before);
|
|
536
|
+
if (!this.alive(generation)) return;
|
|
537
|
+
this.validatePage(page, before);
|
|
538
|
+
rows.push(...page);
|
|
539
|
+
before = page.at(-1) ?? before;
|
|
540
|
+
hasOlder = page.length === this.pageSize;
|
|
541
|
+
if (!hasOlder || !boundary || !previouslyExhausted && before && compare(before, boundary) < 0) break;
|
|
542
|
+
}
|
|
543
|
+
this.cursor = before;
|
|
544
|
+
const reconciled = this.overlay(rows, revision);
|
|
545
|
+
const survivingIds = new Set(reconciled.map((message) => message.id));
|
|
546
|
+
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));
|
|
547
|
+
for (const id of known) if (!survivingIds.has(id)) {
|
|
548
|
+
this.deleted.add(id);
|
|
549
|
+
this.changes.delete(id);
|
|
550
|
+
this.hydrations.delete(id);
|
|
551
|
+
this.hydrationPool.queued.delete(id);
|
|
552
|
+
}
|
|
553
|
+
this.patch({ conversation, messages: reconciled, hasOlderMessages: hasOlder });
|
|
554
|
+
this.mergeReads(conversation.participants.flatMap((participant) => participant.lastReadAt ? [[participant.appUserId, participant.lastReadAt]] : []));
|
|
555
|
+
this.prune(revision);
|
|
242
556
|
} catch (cause) {
|
|
243
|
-
|
|
557
|
+
this.fail(cause, generation, true);
|
|
244
558
|
} finally {
|
|
245
|
-
if (
|
|
246
|
-
|
|
247
|
-
|
|
559
|
+
if (this.alive(generation)) {
|
|
560
|
+
this.patch({ isReconciling: false });
|
|
561
|
+
this.flushRefresh();
|
|
248
562
|
}
|
|
249
563
|
}
|
|
250
564
|
};
|
|
251
|
-
|
|
252
|
-
if (isInitialLoading.
|
|
253
|
-
const
|
|
254
|
-
const
|
|
255
|
-
const
|
|
256
|
-
|
|
257
|
-
error.value = null;
|
|
565
|
+
loadOlderMessages = async () => {
|
|
566
|
+
if (!this.alive() || !this.state.hasLoaded || this.state.isInitialLoading || this.state.isLoadingOlder || this.state.isReconciling || !this.state.hasOlderMessages) return;
|
|
567
|
+
const generation = this.generation;
|
|
568
|
+
const revision = this.revision;
|
|
569
|
+
const cursor = this.cursor;
|
|
570
|
+
this.patch({ isLoadingOlder: true, error: null });
|
|
258
571
|
try {
|
|
259
|
-
const page = await
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
572
|
+
const page = await this.fetchPage(cursor);
|
|
573
|
+
if (!this.alive(generation)) return;
|
|
574
|
+
this.validatePage(page, cursor);
|
|
575
|
+
this.cursor = page.at(-1) ?? cursor;
|
|
576
|
+
this.patch({
|
|
577
|
+
messages: this.overlay(mergeMessages(page, this.state.messages), revision),
|
|
578
|
+
hasOlderMessages: page.length === this.pageSize
|
|
263
579
|
});
|
|
264
|
-
if (disposed || activeGeneration !== generation) return;
|
|
265
|
-
messages.value = mergeMessages(messages.value, page);
|
|
266
|
-
hasOlderMessages.value = page.length === messagePageSize;
|
|
267
580
|
} catch (cause) {
|
|
268
|
-
|
|
581
|
+
this.fail(cause, generation, true);
|
|
269
582
|
} finally {
|
|
270
|
-
if (
|
|
583
|
+
if (this.alive(generation)) {
|
|
584
|
+
this.patch({ isLoadingOlder: false });
|
|
585
|
+
this.flushRefresh();
|
|
586
|
+
}
|
|
271
587
|
}
|
|
272
588
|
};
|
|
273
|
-
|
|
274
|
-
if (
|
|
275
|
-
|
|
276
|
-
void updateTyping(false);
|
|
277
|
-
}, typingTimeoutMs);
|
|
278
|
-
if (sentTyping === nextTyping) return;
|
|
279
|
-
sentTyping = nextTyping;
|
|
589
|
+
markRead = async () => {
|
|
590
|
+
if (!this.alive()) return;
|
|
591
|
+
const generation = this.generation;
|
|
280
592
|
try {
|
|
281
|
-
await
|
|
282
|
-
conversationId: toValue(options.conversationId),
|
|
283
|
-
isTyping: nextTyping
|
|
284
|
-
});
|
|
593
|
+
await this.client.markConversationRead(this.room);
|
|
285
594
|
} catch (cause) {
|
|
286
|
-
|
|
595
|
+
this.fail(cause, generation);
|
|
287
596
|
}
|
|
288
597
|
};
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
598
|
+
updateTyping = async (isTyping) => {
|
|
599
|
+
if (!this.alive()) return;
|
|
600
|
+
const generation = this.generation;
|
|
601
|
+
clearTimeout(this.ownTypingTimer);
|
|
602
|
+
if (isTyping) this.ownTypingTimer = setTimeout(() => {
|
|
603
|
+
if (this.alive(generation)) void this.updateTyping(false);
|
|
604
|
+
}, this.typingTimeout);
|
|
605
|
+
const now = performance.now();
|
|
606
|
+
const renew = isTyping && now - this.lastTypingSentAt >= Math.max(1, this.typingTimeout / 2);
|
|
607
|
+
if (this.sentTyping === isTyping && !renew) return;
|
|
608
|
+
const revision = ++this.typingRevision;
|
|
609
|
+
this.sentTyping = isTyping;
|
|
610
|
+
this.lastTypingSentAt = isTyping ? now : -Infinity;
|
|
611
|
+
try {
|
|
612
|
+
await this.client.sendTyping({ conversationId: this.room, isTyping });
|
|
613
|
+
} catch (cause) {
|
|
614
|
+
if (this.alive(generation) && revision === this.typingRevision) {
|
|
615
|
+
this.sentTyping = false;
|
|
616
|
+
this.fail(cause, generation);
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
};
|
|
620
|
+
sendMessage = async ({ text, media }) => {
|
|
621
|
+
const normalized = text?.trim();
|
|
622
|
+
if (!this.alive() || this.state.isSending || !normalized && !media?.length) return null;
|
|
623
|
+
const generation = this.generation;
|
|
624
|
+
const revision = this.revision;
|
|
625
|
+
this.sendRevision = revision;
|
|
626
|
+
const pendingId = `convokit-pending-${Date.now()}-${++this.pendingSequence}`;
|
|
627
|
+
const pending = {
|
|
295
628
|
id: pendingId,
|
|
296
|
-
conversationId:
|
|
297
|
-
senderId:
|
|
298
|
-
text:
|
|
629
|
+
conversationId: this.room,
|
|
630
|
+
senderId: this.user,
|
|
631
|
+
text: normalized || null,
|
|
299
632
|
media: media ?? [],
|
|
300
633
|
createdAt: /* @__PURE__ */ new Date(),
|
|
301
634
|
updatedAt: null
|
|
302
635
|
};
|
|
303
|
-
|
|
304
|
-
messages.value = mergeMessages(messages.value, [pendingMessage]);
|
|
305
|
-
isSending.value = true;
|
|
306
|
-
error.value = null;
|
|
307
|
-
const activeGeneration = generation;
|
|
636
|
+
this.patch({ messages: mergeMessages(this.state.messages, [pending]), isSending: true, error: null });
|
|
308
637
|
try {
|
|
309
|
-
const message = await
|
|
310
|
-
conversationId:
|
|
311
|
-
...
|
|
638
|
+
const message = await this.client.sendMessage({
|
|
639
|
+
conversationId: this.room,
|
|
640
|
+
...normalized ? { text: normalized } : {},
|
|
312
641
|
...media?.length ? { media } : {}
|
|
313
642
|
});
|
|
314
|
-
|
|
315
|
-
if (!
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
|
|
643
|
+
if (!this.alive(generation)) return null;
|
|
644
|
+
if (!this.validMessage(message) || message.senderId !== this.user) throw new Error("Send response belongs to a different room or sender");
|
|
645
|
+
const live = this.changes.get(message.id);
|
|
646
|
+
const existing = this.state.messages.find((item) => item.id === message.id);
|
|
647
|
+
let latest = existing ? newest(message, existing, live?.complete !== false) : message;
|
|
648
|
+
if (live && live.revision > revision) latest = newest(latest, live.message, live.complete);
|
|
649
|
+
if (!this.deleted.has(message.id)) this.changes.set(message.id, { revision: ++this.revision, message: latest, insert: true, complete: true });
|
|
650
|
+
this.patch({ messages: mergeMessages(
|
|
651
|
+
this.state.messages.filter((item) => item.id !== pendingId),
|
|
652
|
+
this.deleted.has(message.id) ? [] : [latest]
|
|
653
|
+
) });
|
|
654
|
+
void this.updateTyping(false);
|
|
655
|
+
return this.alive(generation) ? latest : null;
|
|
323
656
|
} catch (cause) {
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
error.value = cause;
|
|
657
|
+
if (this.alive(generation)) {
|
|
658
|
+
this.patch({ messages: this.state.messages.filter((item) => item.id !== pendingId) });
|
|
659
|
+
this.fail(cause, generation);
|
|
328
660
|
}
|
|
329
661
|
return null;
|
|
330
662
|
} finally {
|
|
331
|
-
if (
|
|
663
|
+
if (this.alive(generation)) {
|
|
664
|
+
this.sendRevision = void 0;
|
|
665
|
+
this.patch({ isSending: false });
|
|
666
|
+
}
|
|
332
667
|
}
|
|
333
668
|
};
|
|
669
|
+
readerIdsFor = (message) => readerIdsFor(message, this.state.readAtByUserId);
|
|
670
|
+
};
|
|
671
|
+
|
|
672
|
+
// src/composables/use-conversation.ts
|
|
673
|
+
function useConversation(options) {
|
|
674
|
+
const createStore = () => new ConversationStore({
|
|
675
|
+
...options,
|
|
676
|
+
client: toValue(options.client),
|
|
677
|
+
conversationId: toValue(options.conversationId)
|
|
678
|
+
});
|
|
679
|
+
let store = createStore();
|
|
680
|
+
const snapshot = shallowRef(store.getSnapshot());
|
|
681
|
+
let unsubscribe;
|
|
682
|
+
const stop = watch(
|
|
683
|
+
() => [toValue(options.client), toValue(options.client).sessionIdentity, toValue(options.conversationId)],
|
|
684
|
+
() => {
|
|
685
|
+
store.dispose();
|
|
686
|
+
unsubscribe?.();
|
|
687
|
+
store = createStore();
|
|
688
|
+
snapshot.value = store.getSnapshot();
|
|
689
|
+
unsubscribe = store.subscribe(() => {
|
|
690
|
+
snapshot.value = store.getSnapshot();
|
|
691
|
+
});
|
|
692
|
+
store.start(options.autoLoad ?? true);
|
|
693
|
+
},
|
|
694
|
+
{ immediate: true, flush: "sync" }
|
|
695
|
+
);
|
|
696
|
+
const field = (key) => computed(() => snapshot.value[key]);
|
|
334
697
|
const dispose = async () => {
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
await toValue(options.client).sendTyping({
|
|
340
|
-
conversationId: toValue(options.conversationId),
|
|
341
|
-
isTyping: false
|
|
342
|
-
}).catch(() => void 0);
|
|
343
|
-
}
|
|
344
|
-
await unsubscribe();
|
|
698
|
+
stop();
|
|
699
|
+
store.dispose();
|
|
700
|
+
unsubscribe?.();
|
|
701
|
+
unsubscribe = void 0;
|
|
345
702
|
};
|
|
346
|
-
if (options.autoLoad ?? true) {
|
|
347
|
-
watch(
|
|
348
|
-
() => [toValue(options.client), toValue(options.conversationId)],
|
|
349
|
-
() => {
|
|
350
|
-
disposed = false;
|
|
351
|
-
sentTyping = false;
|
|
352
|
-
void loadInitial();
|
|
353
|
-
},
|
|
354
|
-
{ immediate: true }
|
|
355
|
-
);
|
|
356
|
-
}
|
|
357
703
|
if (getCurrentScope()) onScopeDispose(() => {
|
|
358
704
|
void dispose();
|
|
359
705
|
});
|
|
360
706
|
return {
|
|
361
|
-
conversation,
|
|
362
|
-
messages,
|
|
363
|
-
typingUserIds,
|
|
364
|
-
readAtByUserId,
|
|
365
|
-
isInitialLoading,
|
|
366
|
-
isLoadingOlder,
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
707
|
+
conversation: field("conversation"),
|
|
708
|
+
messages: field("messages"),
|
|
709
|
+
typingUserIds: field("typingUserIds"),
|
|
710
|
+
readAtByUserId: field("readAtByUserId"),
|
|
711
|
+
isInitialLoading: field("isInitialLoading"),
|
|
712
|
+
isLoadingOlder: field("isLoadingOlder"),
|
|
713
|
+
isReconciling: field("isReconciling"),
|
|
714
|
+
isSending: field("isSending"),
|
|
715
|
+
hasOlderMessages: field("hasOlderMessages"),
|
|
716
|
+
hasLoaded: field("hasLoaded"),
|
|
717
|
+
error: field("error"),
|
|
718
|
+
currentUserId: field("currentUserId"),
|
|
719
|
+
readerIdsFor: (message) => store.readerIdsFor(message),
|
|
720
|
+
loadInitial: () => store.loadInitial(),
|
|
721
|
+
refresh: () => store.refresh(),
|
|
722
|
+
loadOlderMessages: () => store.loadOlderMessages(),
|
|
723
|
+
sendMessage: (input) => store.sendMessage(input),
|
|
724
|
+
markRead: () => store.markRead(),
|
|
725
|
+
updateTyping: (isTyping) => store.updateTyping(isTyping),
|
|
379
726
|
dispose
|
|
380
727
|
};
|
|
381
728
|
}
|
|
@@ -397,7 +744,7 @@ import {
|
|
|
397
744
|
defineComponent as defineComponent2,
|
|
398
745
|
h as h2,
|
|
399
746
|
nextTick,
|
|
400
|
-
ref
|
|
747
|
+
ref,
|
|
401
748
|
watch as watch2
|
|
402
749
|
} from "vue";
|
|
403
750
|
var appearanceProps = {
|
|
@@ -463,7 +810,7 @@ var MessageListView = defineComponent2({
|
|
|
463
810
|
},
|
|
464
811
|
emits: ["load-older", "attachment-click"],
|
|
465
812
|
setup(props, { attrs, emit, slots }) {
|
|
466
|
-
const internalElement =
|
|
813
|
+
const internalElement = ref(null);
|
|
467
814
|
let requestInFlight = false;
|
|
468
815
|
let lastRequestedLength = null;
|
|
469
816
|
let previousMessageCount = 0;
|
|
@@ -506,7 +853,8 @@ var MessageListView = defineComponent2({
|
|
|
506
853
|
const renderMessage = (message, index) => {
|
|
507
854
|
const isCurrentUser = message.senderId === props.currentUserId;
|
|
508
855
|
const sender = participants.value.get(message.senderId);
|
|
509
|
-
const
|
|
856
|
+
const isPending = isConvoKitPendingMessage(message);
|
|
857
|
+
const readerIds = isPending ? /* @__PURE__ */ new Set() : props.readersResolver ? props.readersResolver(message) : readerIdsFor(message, props.readAtByUserId);
|
|
510
858
|
const slotProps = { message, chronologicalIndex: index, isCurrentUser, sender, readerIds };
|
|
511
859
|
const custom = slots.message?.(slotProps);
|
|
512
860
|
if (custom) return h2("div", { key: message.id, role: "listitem" }, custom);
|
|
@@ -539,12 +887,12 @@ var MessageListView = defineComponent2({
|
|
|
539
887
|
!isCurrentUser ? h2("strong", { class: "ckui-message-sender" }, sender?.name || message.senderId) : null,
|
|
540
888
|
message.text ? h2("div", { class: "ckui-message-text" }, message.text) : null,
|
|
541
889
|
...mediaNodes,
|
|
542
|
-
h2("
|
|
543
|
-
props.formatTime(message.createdAt),
|
|
544
|
-
isCurrentUser ? readerIds.size > 0 ? h2(CheckCheck, { size: 14, "aria-label": "Read" }) : h2(Check, { size: 14, "aria-label": "Delivered" }) : null
|
|
890
|
+
h2("span", { class: "ckui-message-time" }, [
|
|
891
|
+
isPending ? "Sending\u2026" : props.formatTime(message.createdAt),
|
|
892
|
+
isCurrentUser && !isPending ? readerIds.size > 0 ? h2(CheckCheck, { size: 14, "aria-label": "Read" }) : h2(Check, { size: 14, "aria-label": "Delivered" }) : null
|
|
545
893
|
])
|
|
546
894
|
]),
|
|
547
|
-
isCurrentUser ? slots["read-receipt"]?.(receiptSlotProps) ?? h2("div", {
|
|
895
|
+
isCurrentUser && !isPending ? slots["read-receipt"]?.(receiptSlotProps) ?? h2("div", {
|
|
548
896
|
class: partClass("receipt", currentAppearance, "ckui-read-receipt"),
|
|
549
897
|
style: partStyle("receipt", currentAppearance)
|
|
550
898
|
}, readerIds.size > 0 ? `Read by ${readerIds.size}` : "Delivered") : null
|
|
@@ -672,8 +1020,8 @@ var ConversationView = defineComponent3({
|
|
|
672
1020
|
"update:modelValue"
|
|
673
1021
|
],
|
|
674
1022
|
setup(props, { attrs, emit, slots }) {
|
|
675
|
-
const internalDraft =
|
|
676
|
-
const submitting =
|
|
1023
|
+
const internalDraft = ref2(props.defaultDraft);
|
|
1024
|
+
const submitting = ref2(false);
|
|
677
1025
|
const appearance = () => ({
|
|
678
1026
|
density: props.density,
|
|
679
1027
|
unstyled: props.unstyled,
|
|
@@ -1001,119 +1349,193 @@ import { ChevronRight, Inbox, LoaderCircle as LoaderCircle3, RefreshCw as Refres
|
|
|
1001
1349
|
import {
|
|
1002
1350
|
defineComponent as defineComponent4,
|
|
1003
1351
|
h as h4,
|
|
1004
|
-
ref as
|
|
1352
|
+
ref as ref3,
|
|
1005
1353
|
watchEffect as watchEffect2
|
|
1006
1354
|
} from "vue";
|
|
1007
1355
|
|
|
1008
1356
|
// src/composables/use-conversation-list.ts
|
|
1009
|
-
import {
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1357
|
+
import { computed as computed3, getCurrentScope as getCurrentScope2, onScopeDispose as onScopeDispose2, shallowRef as shallowRef2, toValue as toValue2, watch as watch3 } from "vue";
|
|
1358
|
+
|
|
1359
|
+
// src/conversation-list-store.ts
|
|
1360
|
+
function blank2(filter) {
|
|
1361
|
+
return { conversations: [], filter, isInitialLoading: false, isLoadingMore: false, hasMore: true, hasLoaded: false, error: null };
|
|
1362
|
+
}
|
|
1363
|
+
var ConversationListStore = class {
|
|
1364
|
+
constructor(options) {
|
|
1365
|
+
this.options = options;
|
|
1366
|
+
this.owner = options.client.sessionIdentity;
|
|
1367
|
+
this.pageSize = options.pageSize ?? 30;
|
|
1368
|
+
if (!Number.isInteger(this.pageSize) || this.pageSize < 1 || this.pageSize > 100) {
|
|
1369
|
+
throw new RangeError("pageSize must be an integer between 1 and 100");
|
|
1370
|
+
}
|
|
1371
|
+
this.state = blank2(options.initialFilter ?? {});
|
|
1372
|
+
}
|
|
1373
|
+
options;
|
|
1374
|
+
owner;
|
|
1375
|
+
pageSize;
|
|
1376
|
+
state;
|
|
1377
|
+
source = [];
|
|
1378
|
+
offset = 0;
|
|
1379
|
+
generation = 0;
|
|
1380
|
+
lifecycleGeneration = 0;
|
|
1381
|
+
disposed = true;
|
|
1382
|
+
lifecycle;
|
|
1383
|
+
listeners = /* @__PURE__ */ new Set();
|
|
1384
|
+
getSnapshot = () => this.state;
|
|
1385
|
+
subscribe = (listener) => {
|
|
1386
|
+
this.listeners.add(listener);
|
|
1387
|
+
return () => {
|
|
1388
|
+
this.listeners.delete(listener);
|
|
1389
|
+
};
|
|
1390
|
+
};
|
|
1391
|
+
patch(patch) {
|
|
1392
|
+
this.state = { ...this.state, ...patch };
|
|
1393
|
+
for (const listener of this.listeners) listener();
|
|
1394
|
+
}
|
|
1395
|
+
alive(generation = this.generation) {
|
|
1396
|
+
return !this.disposed && generation === this.generation && this.owner !== null && this.options.client.sessionIdentity === this.owner;
|
|
1397
|
+
}
|
|
1398
|
+
start = (autoLoad = true) => {
|
|
1399
|
+
if (!this.owner || this.options.client.sessionIdentity !== this.owner) return;
|
|
1400
|
+
this.disposed = false;
|
|
1401
|
+
const lifecycleGeneration = ++this.lifecycleGeneration;
|
|
1402
|
+
try {
|
|
1403
|
+
const subscription = this.options.client.onConnectionEvent({
|
|
1404
|
+
onEvent: () => {
|
|
1405
|
+
},
|
|
1406
|
+
onSessionEnded: () => {
|
|
1407
|
+
if (!this.disposed && lifecycleGeneration === this.lifecycleGeneration) this.dispose();
|
|
1408
|
+
}
|
|
1040
1409
|
});
|
|
1041
|
-
if (
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
if (
|
|
1410
|
+
if (this.alive()) this.lifecycle = subscription;
|
|
1411
|
+
else void subscription.unsubscribe().catch(() => void 0);
|
|
1412
|
+
if (autoLoad) void this.loadInitial();
|
|
1413
|
+
} catch (cause) {
|
|
1414
|
+
if (this.alive()) this.patch({ error: cause });
|
|
1046
1415
|
}
|
|
1047
1416
|
};
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1417
|
+
dispose = () => {
|
|
1418
|
+
this.disposed = true;
|
|
1419
|
+
this.generation++;
|
|
1420
|
+
this.lifecycleGeneration++;
|
|
1421
|
+
const subscription = this.lifecycle;
|
|
1422
|
+
this.lifecycle = void 0;
|
|
1423
|
+
if (subscription) void subscription.unsubscribe().catch(() => void 0);
|
|
1424
|
+
this.source = [];
|
|
1425
|
+
this.offset = 0;
|
|
1426
|
+
this.patch(blank2(this.state.filter));
|
|
1427
|
+
};
|
|
1428
|
+
fail(cause, generation) {
|
|
1429
|
+
if (!this.alive(generation)) return;
|
|
1430
|
+
const status = typeof cause === "object" && cause !== null && "status" in cause ? cause.status : void 0;
|
|
1431
|
+
if (status === 401 || status === 403 || status === 404) {
|
|
1432
|
+
this.source = [];
|
|
1433
|
+
this.offset = 0;
|
|
1434
|
+
this.patch({ conversations: [], hasMore: false });
|
|
1435
|
+
}
|
|
1436
|
+
this.patch({ error: cause });
|
|
1437
|
+
}
|
|
1438
|
+
async loadUntilVisible(generation, filter) {
|
|
1439
|
+
const visibleBefore = applyConversationFilter(this.source, filter).length;
|
|
1440
|
+
while (this.alive(generation)) {
|
|
1441
|
+
const request = { limit: this.pageSize, offset: this.offset, filter };
|
|
1442
|
+
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 });
|
|
1443
|
+
if (!this.alive(generation)) return;
|
|
1444
|
+
if (page.length > this.pageSize || page.some((conversation) => !conversation.id.trim())) {
|
|
1445
|
+
throw new Error("Invalid conversation page");
|
|
1446
|
+
}
|
|
1447
|
+
const merged = mergeConversations(this.source, page);
|
|
1448
|
+
if (page.length === this.pageSize && merged.length === this.source.length) {
|
|
1449
|
+
throw new Error("Conversation pagination did not advance");
|
|
1450
|
+
}
|
|
1451
|
+
this.offset += page.length;
|
|
1452
|
+
this.source = merged;
|
|
1453
|
+
const visible = applyConversationFilter(merged, filter);
|
|
1454
|
+
const hasMore = page.length === this.pageSize;
|
|
1455
|
+
this.patch({ conversations: visible, hasMore });
|
|
1456
|
+
if (!hasMore || visible.length > visibleBefore) return;
|
|
1457
|
+
}
|
|
1458
|
+
}
|
|
1459
|
+
loadInitial = async () => {
|
|
1460
|
+
if (!this.alive()) return;
|
|
1461
|
+
const generation = ++this.generation;
|
|
1462
|
+
this.source = [];
|
|
1463
|
+
this.offset = 0;
|
|
1464
|
+
this.patch({ ...blank2(this.state.filter), isInitialLoading: true });
|
|
1056
1465
|
try {
|
|
1057
|
-
await loadUntilVisible(
|
|
1466
|
+
await this.loadUntilVisible(generation, this.state.filter);
|
|
1058
1467
|
} catch (cause) {
|
|
1059
|
-
|
|
1468
|
+
this.fail(cause, generation);
|
|
1060
1469
|
} finally {
|
|
1061
|
-
if (
|
|
1062
|
-
isInitialLoading.value = false;
|
|
1063
|
-
hasLoaded.value = true;
|
|
1064
|
-
}
|
|
1470
|
+
if (this.alive(generation)) this.patch({ isInitialLoading: false, hasLoaded: true });
|
|
1065
1471
|
}
|
|
1066
1472
|
};
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1473
|
+
refresh = () => this.loadInitial();
|
|
1474
|
+
loadMore = async () => {
|
|
1475
|
+
if (!this.alive() || this.state.isInitialLoading || this.state.isLoadingMore || !this.state.hasMore) return;
|
|
1476
|
+
const generation = this.generation;
|
|
1477
|
+
this.patch({ isLoadingMore: true, error: null });
|
|
1072
1478
|
try {
|
|
1073
|
-
await loadUntilVisible(
|
|
1479
|
+
await this.loadUntilVisible(generation, this.state.filter);
|
|
1074
1480
|
} catch (cause) {
|
|
1075
|
-
|
|
1481
|
+
this.fail(cause, generation);
|
|
1076
1482
|
} finally {
|
|
1077
|
-
if (
|
|
1483
|
+
if (this.alive(generation)) this.patch({ isLoadingMore: false });
|
|
1078
1484
|
}
|
|
1079
1485
|
};
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
filter.
|
|
1083
|
-
|
|
1084
|
-
if (
|
|
1085
|
-
|
|
1086
|
-
} else if (applyConversationFilter(source.value, nextFilter).length === 0 && hasMore.value) {
|
|
1087
|
-
await loadMore();
|
|
1088
|
-
}
|
|
1486
|
+
setFilter = async (filter) => {
|
|
1487
|
+
if (!this.alive()) return;
|
|
1488
|
+
const reload = this.options.pageLoader || !this.state.hasLoaded || this.state.isInitialLoading || this.state.isLoadingMore || (filter.archived ?? false) !== (this.state.filter.archived ?? false);
|
|
1489
|
+
this.patch({ filter, conversations: applyConversationFilter(this.source, filter), error: null });
|
|
1490
|
+
if (reload) await this.loadInitial();
|
|
1491
|
+
else if (!this.state.conversations.length && this.state.hasMore) await this.loadMore();
|
|
1089
1492
|
};
|
|
1090
|
-
|
|
1493
|
+
setQuery = (query) => this.setFilter({ ...this.state.filter, query });
|
|
1494
|
+
};
|
|
1495
|
+
|
|
1496
|
+
// src/composables/use-conversation-list.ts
|
|
1497
|
+
function useConversationList(options) {
|
|
1498
|
+
const createStore = () => new ConversationListStore({ ...options, client: toValue2(options.client) });
|
|
1499
|
+
let store = createStore();
|
|
1500
|
+
const snapshot = shallowRef2(store.getSnapshot());
|
|
1501
|
+
let unsubscribe;
|
|
1502
|
+
const stop = watch3(
|
|
1503
|
+
() => [toValue2(options.client), toValue2(options.client).sessionIdentity],
|
|
1504
|
+
() => {
|
|
1505
|
+
store.dispose();
|
|
1506
|
+
unsubscribe?.();
|
|
1507
|
+
store = createStore();
|
|
1508
|
+
snapshot.value = store.getSnapshot();
|
|
1509
|
+
unsubscribe = store.subscribe(() => {
|
|
1510
|
+
snapshot.value = store.getSnapshot();
|
|
1511
|
+
});
|
|
1512
|
+
store.start(options.autoLoad ?? true);
|
|
1513
|
+
},
|
|
1514
|
+
{ immediate: true, flush: "sync" }
|
|
1515
|
+
);
|
|
1516
|
+
const field = (key) => computed3(() => snapshot.value[key]);
|
|
1091
1517
|
const dispose = async () => {
|
|
1092
|
-
|
|
1093
|
-
|
|
1518
|
+
stop();
|
|
1519
|
+
store.dispose();
|
|
1520
|
+
unsubscribe?.();
|
|
1521
|
+
unsubscribe = void 0;
|
|
1094
1522
|
};
|
|
1095
|
-
if (options.autoLoad ?? true) {
|
|
1096
|
-
watch3(() => toValue2(options.client), () => {
|
|
1097
|
-
disposed = false;
|
|
1098
|
-
void loadInitialFor(filter.value);
|
|
1099
|
-
}, { immediate: true });
|
|
1100
|
-
}
|
|
1101
1523
|
if (getCurrentScope2()) onScopeDispose2(() => {
|
|
1102
1524
|
void dispose();
|
|
1103
1525
|
});
|
|
1104
1526
|
return {
|
|
1105
|
-
conversations,
|
|
1106
|
-
filter,
|
|
1107
|
-
isInitialLoading,
|
|
1108
|
-
isLoadingMore,
|
|
1109
|
-
hasMore,
|
|
1110
|
-
hasLoaded,
|
|
1111
|
-
error,
|
|
1112
|
-
loadInitial:
|
|
1113
|
-
refresh:
|
|
1114
|
-
loadMore,
|
|
1115
|
-
setFilter,
|
|
1116
|
-
setQuery,
|
|
1527
|
+
conversations: field("conversations"),
|
|
1528
|
+
filter: field("filter"),
|
|
1529
|
+
isInitialLoading: field("isInitialLoading"),
|
|
1530
|
+
isLoadingMore: field("isLoadingMore"),
|
|
1531
|
+
hasMore: field("hasMore"),
|
|
1532
|
+
hasLoaded: field("hasLoaded"),
|
|
1533
|
+
error: field("error"),
|
|
1534
|
+
loadInitial: () => store.loadInitial(),
|
|
1535
|
+
refresh: () => store.refresh(),
|
|
1536
|
+
loadMore: () => store.loadMore(),
|
|
1537
|
+
setFilter: (filter) => store.setFilter(filter),
|
|
1538
|
+
setQuery: (query) => store.setQuery(query),
|
|
1117
1539
|
dispose
|
|
1118
1540
|
};
|
|
1119
1541
|
}
|
|
@@ -1150,7 +1572,7 @@ var ConversationListView = defineComponent4({
|
|
|
1150
1572
|
"load-more": () => true
|
|
1151
1573
|
},
|
|
1152
1574
|
setup(props, { attrs, emit, slots }) {
|
|
1153
|
-
const internalElement =
|
|
1575
|
+
const internalElement = ref3(null);
|
|
1154
1576
|
let requestInFlight = false;
|
|
1155
1577
|
let lastRequestedLength = null;
|
|
1156
1578
|
const appearance = () => ({
|
|
@@ -1431,6 +1853,7 @@ export {
|
|
|
1431
1853
|
defaultConvoKitTheme,
|
|
1432
1854
|
defaultReadersResolver,
|
|
1433
1855
|
formatFileSize,
|
|
1856
|
+
isConvoKitPendingMessage,
|
|
1434
1857
|
matchesConversation,
|
|
1435
1858
|
mergeConversations,
|
|
1436
1859
|
mergeMessages,
|