@convokitapp/vue-ui 0.4.1 → 0.5.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 +33 -0
- package/PARITY.md +13 -6
- package/README.md +46 -0
- package/dist/index.cjs +147 -40
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +50 -5
- package/dist/index.d.ts +50 -5
- package/dist/index.js +146 -38
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,38 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.5.0
|
|
4
|
+
|
|
5
|
+
- Precise read positions. Receipts resolve per user from a monotonic read-through
|
|
6
|
+
position `(createdAt, id)` when the server provides one, and fall back to the
|
|
7
|
+
acknowledgement time only for legacy participants without a position. A read
|
|
8
|
+
request that arrives late no longer marks messages that were rendered after it
|
|
9
|
+
as read. The state exposes `readPositionByUserId` beside `readAtByUserId`;
|
|
10
|
+
`readerIdsFor`, `defaultReadersResolver`, `MessageListView` and
|
|
11
|
+
`ConversationView` accept both (`readAtByUserId` alone still works).
|
|
12
|
+
- Targeted acknowledgements. Automatic and explicit reads name the newest
|
|
13
|
+
rendered, non-pending message (`throughMessageId`); nothing is sent while
|
|
14
|
+
nothing is rendered. A withheld media-only row is acknowledged once its
|
|
15
|
+
hydration renders it. One request is in flight at a time, a follow-up is
|
|
16
|
+
resolved at send time, and targets at or before the accepted one are skipped.
|
|
17
|
+
A target the server does not know (`MESSAGE_NOT_FOUND`) is skipped and the
|
|
18
|
+
next newest row is acknowledged once; membership failures still surface as
|
|
19
|
+
errors.
|
|
20
|
+
- Visibility gating. Automatic acknowledgements wait while the document is
|
|
21
|
+
hidden and resume when it becomes visible; the SDK-backed `Conversation`
|
|
22
|
+
wires `document.visibilityState` and `visibilitychange`, and controllers gain
|
|
23
|
+
`setVisible(visible)`. Unknown, prerender and server environments count as
|
|
24
|
+
visible. `markReadOnLoad` / `markReadOnReceive` keep their meaning; with both
|
|
25
|
+
off no request is ever sent, including on visibility changes.
|
|
26
|
+
- 0.5.0 adapter change: `ConvoKitUiClient.markConversationRead(conversationId,
|
|
27
|
+
options?)` receives `{ throughMessageId }`. Custom adapters must forward it
|
|
28
|
+
and report an unknown target with `code: 'MESSAGE_NOT_FOUND'` (or a 404
|
|
29
|
+
without a code). One-argument implementations still type-check but would
|
|
30
|
+
acknowledge the server's newest message instead of the rendered one.
|
|
31
|
+
- Requires core SDK 0.5.x (`Participant.readPosition`, `ReadEvent.readPosition`,
|
|
32
|
+
`markConversationRead` target) and the coordinated backend. Mixed fleet:
|
|
33
|
+
precise receipts need both the sender's and the reader's clients on 0.5;
|
|
34
|
+
0.4 readers keep timestamp semantics and keep parsing the additive payload.
|
|
35
|
+
|
|
3
36
|
## 0.4.1
|
|
4
37
|
|
|
5
38
|
- Describe a confirmed outgoing message without readers as `Sent` instead of
|
package/PARITY.md
CHANGED
|
@@ -18,6 +18,9 @@ while using Vue-native composition patterns.
|
|
|
18
18
|
| Realtime messages, typing, reads | Yes | Yes | Yes |
|
|
19
19
|
| Pending state without provisional timestamps or receipts | Yes | Yes | Yes |
|
|
20
20
|
| Read markers and reader resolution | Yes | Yes | Yes |
|
|
21
|
+
| Read positions (`readPositionByUserId`, `lastReadAt` fallback) | Yes | Yes | Yes |
|
|
22
|
+
| Targeted acknowledgements (`throughMessageId`, newest rendered row) | Yes | Yes | Yes |
|
|
23
|
+
| Visibility-gated auto-read | `WidgetsBindingObserver` | `visibilitychange` | `visibilitychange` |
|
|
21
24
|
| Mark read on load/receive | Yes | Yes | Yes |
|
|
22
25
|
| Typing idle timeout | Yes | Yes | Yes |
|
|
23
26
|
| Image, file, location, contact rendering | Yes | Yes | Yes |
|
|
@@ -32,10 +35,14 @@ Vue also exposes controlled/uncontrolled composer text through `v-model`,
|
|
|
32
35
|
per-part `classNames` and `styles`, `unstyled` mode, native root attributes,
|
|
33
36
|
image loading policy, time formatting, scroll behavior controls, callback props,
|
|
34
37
|
and exposed composable controllers.
|
|
35
|
-
##
|
|
38
|
+
## Read-position parity (0.5.0)
|
|
36
39
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
40
|
+
Every UI applies the same receipt rule per user: a read position wins when
|
|
41
|
+
present (`covers` by `(createdAt, id)`), otherwise `lastReadAt` is compared
|
|
42
|
+
with `createdAt`. Acknowledgements target the newest rendered non-pending
|
|
43
|
+
message, coalesce to one request in flight, skip targets at or before the last
|
|
44
|
+
accepted one, re-issue once on a `MESSAGE_NOT_FOUND` miss, and wait while the
|
|
45
|
+
platform reports the view as hidden. The adapter interface gained the optional
|
|
46
|
+
target in all libraries (`markConversationRead(conversationId, options?)` here).
|
|
47
|
+
The parity script checks API presence only; the store, rendered-view and
|
|
48
|
+
actual-SDK protocol tests verify behavior.
|
package/README.md
CHANGED
|
@@ -11,6 +11,48 @@ React package: system typography, separated rows, subtle borders, compact
|
|
|
11
11
|
actions, and restrained radii. It remains framework-CSS independent and does
|
|
12
12
|
not require Tailwind in the host application.
|
|
13
13
|
|
|
14
|
+
## 0.5.0 precise read positions
|
|
15
|
+
|
|
16
|
+
This release requires JavaScript SDK 0.5.x and the coordinated backend.
|
|
17
|
+
|
|
18
|
+
- Read receipts resolve per user from a monotonic read-through position, the
|
|
19
|
+
`(createdAt, id)` of a concrete message the server resolved, and fall back to
|
|
20
|
+
the acknowledgement time only for legacy participants without a position. A
|
|
21
|
+
read request that arrives late no longer marks messages rendered after it as
|
|
22
|
+
read. `useConversation` and `Conversation` expose `readPositionByUserId`
|
|
23
|
+
beside `readAtByUserId`; both are seeded from participants and advanced by
|
|
24
|
+
server read events only, never from the device clock.
|
|
25
|
+
- Acknowledgements name the newest rendered, non-pending message
|
|
26
|
+
(`throughMessageId`). Nothing is sent while nothing is rendered, and a
|
|
27
|
+
media-only row is acknowledged only once its hydration renders it. One
|
|
28
|
+
request is in flight at a time; a follow-up is resolved at send time and
|
|
29
|
+
skipped when the newest row is already acknowledged. A target the server
|
|
30
|
+
does not know (`MESSAGE_NOT_FOUND`, for example deleted meanwhile) is
|
|
31
|
+
skipped and the next newest row is acknowledged once. Membership failures
|
|
32
|
+
still surface as errors.
|
|
33
|
+
- Automatic acknowledgements wait while the document is hidden and resume when
|
|
34
|
+
it becomes visible. The SDK-backed `Conversation` follows
|
|
35
|
+
`document.visibilityState` and `visibilitychange`; controllers gain
|
|
36
|
+
`setVisible(visible)` for host-driven surfaces (tabs, drawers, background
|
|
37
|
+
routes). Unknown, prerender and server environments count as visible.
|
|
38
|
+
`markReadOnLoad` and `markReadOnReceive` keep their meaning; with both off,
|
|
39
|
+
no request is ever sent, including on visibility changes. `markRead()` is
|
|
40
|
+
the host's decision and is not gated.
|
|
41
|
+
- Controlled views accept `readPositionByUserId` next to `readAtByUserId`;
|
|
42
|
+
`readerIdsFor(message, readAtByUserId, readPositionByUserId?)` and
|
|
43
|
+
`defaultReadersResolver` apply the unified rule. Custom `#message` and
|
|
44
|
+
`#read-receipt` slots and `readersResolver` receive the same reader IDs.
|
|
45
|
+
- Adapter change: `ConvoKitUiClient.markConversationRead(conversationId,
|
|
46
|
+
options?)` receives `{ throughMessageId }`. Custom adapters must forward it
|
|
47
|
+
to the core SDK (or their backend) and report an unknown target with
|
|
48
|
+
`code: 'MESSAGE_NOT_FOUND'` (or a 404 without a code). One-argument
|
|
49
|
+
implementations still type-check but acknowledge the server's newest message
|
|
50
|
+
instead of the rendered one.
|
|
51
|
+
- Mixed fleet: precise receipts need both the sender's and the reader's
|
|
52
|
+
clients on 0.5. 0.4 readers keep timestamp semantics and keep parsing the
|
|
53
|
+
additive payload; the dashboard shows both the read-through and the last
|
|
54
|
+
acknowledgement time.
|
|
55
|
+
|
|
14
56
|
## 0.4.0 live inbox, sending and recovery
|
|
15
57
|
|
|
16
58
|
This release requires JavaScript SDK 0.4.x and the coordinated backend. The core
|
|
@@ -45,6 +87,7 @@ membership changes; no additional customer configuration is needed.
|
|
|
45
87
|
- Read receipts use persisted participant positions and server read events,
|
|
46
88
|
monotonically. A successful mark-read request does not fabricate a timestamp
|
|
47
89
|
from the device clock. Remote typing expires and clears on disconnect.
|
|
90
|
+
(0.5.0 replaces the timestamp comparison with read positions; see above.)
|
|
48
91
|
- Pending sends are bound to their original room/session. Reconciliation uses
|
|
49
92
|
the exact clientMessageId, sender and room, never identical text or file count.
|
|
50
93
|
A matching live/history row replaces its pending bubble immediately, even
|
|
@@ -148,6 +191,9 @@ Available slots include `conversation-item`, `separator`, `header`, `message`,
|
|
|
148
191
|
Use `useConversationList` and `useConversation` when you want ConvoKit's
|
|
149
192
|
pagination, de-duplication, realtime, typing, and read state without the
|
|
150
193
|
default UI. Both return readonly Vue refs plus actions and a `dispose()` method.
|
|
194
|
+
`useConversation` also exposes `readPositionByUserId`, `readerIdsFor(message)`,
|
|
195
|
+
`markRead()` and `setVisible(visible)`; call `setVisible(false)` while your own
|
|
196
|
+
surface hides the room so automatic acknowledgements wait until it is shown.
|
|
151
197
|
|
|
152
198
|
Optimistic rows display `Sending…` until acknowledgement. The final server
|
|
153
199
|
timestamp is formatted in the viewer's local timezone. Custom message slots can
|
package/dist/index.cjs
CHANGED
|
@@ -66,7 +66,7 @@ function createConvoKitUiClient(client) {
|
|
|
66
66
|
getMessages: (options) => client.getMessages(options),
|
|
67
67
|
getMessage: (id) => client.getMessage(id),
|
|
68
68
|
sendMessage: (input) => client.sendMessage(input),
|
|
69
|
-
markConversationRead: (conversationId) => client.markConversationRead(conversationId),
|
|
69
|
+
markConversationRead: (conversationId, options) => client.markConversationRead(conversationId, options),
|
|
70
70
|
sendTyping: (input) => client.sendTyping(input),
|
|
71
71
|
onMessage: (conversationId, handler, onError) => client.realtime.onMessage(conversationId, {
|
|
72
72
|
onEvent: handler,
|
|
@@ -88,9 +88,13 @@ var import_reka_ui = require("reka-ui");
|
|
|
88
88
|
var import_vue2 = require("vue");
|
|
89
89
|
|
|
90
90
|
// src/utils.ts
|
|
91
|
+
var import_sdk = require("@convokitapp/sdk");
|
|
91
92
|
var import_clsx = require("clsx");
|
|
92
93
|
var import_vue = require("vue");
|
|
93
94
|
var pendingMessageIdPrefix = "convokit-pending-";
|
|
95
|
+
function compareMessageOrder(left, right) {
|
|
96
|
+
return left.createdAt.getTime() - right.createdAt.getTime() || (left.id < right.id ? -1 : left.id > right.id ? 1 : 0);
|
|
97
|
+
}
|
|
94
98
|
function isConvoKitPendingMessage(message) {
|
|
95
99
|
return message.id.startsWith(pendingMessageIdPrefix);
|
|
96
100
|
}
|
|
@@ -139,13 +143,16 @@ function mergeMessages(current, incoming) {
|
|
|
139
143
|
const leftPending = isConvoKitPendingMessage(left);
|
|
140
144
|
const rightPending = isConvoKitPendingMessage(right);
|
|
141
145
|
if (leftPending !== rightPending) return leftPending ? 1 : -1;
|
|
142
|
-
|
|
143
|
-
return byTime === 0 ? left.id.localeCompare(right.id) : byTime;
|
|
146
|
+
return compareMessageOrder(left, right);
|
|
144
147
|
});
|
|
145
148
|
}
|
|
146
|
-
function readerIdsFor(message, readAtByUserId) {
|
|
149
|
+
function readerIdsFor(message, readAtByUserId, readPositionByUserId = /* @__PURE__ */ new Map()) {
|
|
147
150
|
if (isConvoKitPendingMessage(message)) return /* @__PURE__ */ new Set();
|
|
148
|
-
|
|
151
|
+
const userIds = /* @__PURE__ */ new Set([...readAtByUserId.keys(), ...readPositionByUserId.keys()]);
|
|
152
|
+
return new Set([...userIds].filter((userId) => userId !== message.senderId && (0, import_sdk.readThrough)({
|
|
153
|
+
readPosition: readPositionByUserId.get(userId) ?? null,
|
|
154
|
+
lastReadAt: readAtByUserId.get(userId) ?? null
|
|
155
|
+
}, message)));
|
|
149
156
|
}
|
|
150
157
|
function partClass(part, appearance, defaultClass) {
|
|
151
158
|
return cx(!appearance.unstyled && defaultClass, appearance.classNames?.[part]);
|
|
@@ -199,7 +206,7 @@ var import_vue7 = require("vue");
|
|
|
199
206
|
var import_vue3 = require("vue");
|
|
200
207
|
|
|
201
208
|
// src/conversation-store.ts
|
|
202
|
-
var
|
|
209
|
+
var import_sdk2 = require("@convokitapp/sdk");
|
|
203
210
|
function version(message) {
|
|
204
211
|
return message.updatedAt?.getTime() ?? message.createdAt.getTime();
|
|
205
212
|
}
|
|
@@ -209,8 +216,20 @@ function hasContent(message) {
|
|
|
209
216
|
function newest(current, incoming, incomingComplete = true) {
|
|
210
217
|
return version(current) > version(incoming) || !incomingComplete && version(current) === version(incoming) ? current : incoming;
|
|
211
218
|
}
|
|
212
|
-
|
|
213
|
-
|
|
219
|
+
var compare = compareMessageOrder;
|
|
220
|
+
function positionCursor(position) {
|
|
221
|
+
return { createdAt: position.createdAt, id: position.messageId };
|
|
222
|
+
}
|
|
223
|
+
function readEntry(participant) {
|
|
224
|
+
return { userId: participant.appUserId, readAt: participant.lastReadAt, readPosition: participant.readPosition };
|
|
225
|
+
}
|
|
226
|
+
function acknowledgement() {
|
|
227
|
+
return { inFlight: void 0, followUp: false, suppressed: false, target: void 0, acknowledged: void 0, unacknowledgeable: /* @__PURE__ */ new Set() };
|
|
228
|
+
}
|
|
229
|
+
function isTargetMiss(cause) {
|
|
230
|
+
if (typeof cause !== "object" || cause === null) return false;
|
|
231
|
+
const { code, status } = cause;
|
|
232
|
+
return code === "MESSAGE_NOT_FOUND" || code === void 0 && status === 404;
|
|
214
233
|
}
|
|
215
234
|
function blank(currentUserId = "") {
|
|
216
235
|
return {
|
|
@@ -218,6 +237,7 @@ function blank(currentUserId = "") {
|
|
|
218
237
|
messages: [],
|
|
219
238
|
typingUserIds: /* @__PURE__ */ new Set(),
|
|
220
239
|
readAtByUserId: /* @__PURE__ */ new Map(),
|
|
240
|
+
readPositionByUserId: /* @__PURE__ */ new Map(),
|
|
221
241
|
isInitialLoading: false,
|
|
222
242
|
isLoadingOlder: false,
|
|
223
243
|
isReconciling: false,
|
|
@@ -265,6 +285,9 @@ var ConversationStore = class {
|
|
|
265
285
|
hydrationPool = { running: /* @__PURE__ */ new Set(), queued: /* @__PURE__ */ new Map() };
|
|
266
286
|
// Keep tombstones until an explicit reload/session change, including across refreshes.
|
|
267
287
|
deleted = /* @__PURE__ */ new Set();
|
|
288
|
+
ack = acknowledgement();
|
|
289
|
+
// Visible until the platform reports otherwise; unknown/prerender/no document count as visible.
|
|
290
|
+
visible = true;
|
|
268
291
|
sendRevision;
|
|
269
292
|
activeSend;
|
|
270
293
|
refreshQueued = false;
|
|
@@ -327,6 +350,7 @@ var ConversationStore = class {
|
|
|
327
350
|
this.hydrations.clear();
|
|
328
351
|
this.hydrationPool.queued.clear();
|
|
329
352
|
this.deleted.clear();
|
|
353
|
+
this.ack = acknowledgement();
|
|
330
354
|
this.sendRevision = void 0;
|
|
331
355
|
this.activeSend = void 0;
|
|
332
356
|
this.refreshQueued = false;
|
|
@@ -382,8 +406,8 @@ var ConversationStore = class {
|
|
|
382
406
|
if (!this.alive(generation) || conversationId !== this.room || !id.trim()) return;
|
|
383
407
|
this.removeMessage(id);
|
|
384
408
|
}, report));
|
|
385
|
-
add(() => this.client.onReadReceipt(this.room, ({ userId, readAt }) => {
|
|
386
|
-
if (this.alive(generation)) this.mergeReads([
|
|
409
|
+
add(() => this.client.onReadReceipt(this.room, ({ userId, readAt, readPosition }) => {
|
|
410
|
+
if (this.alive(generation)) this.mergeReads([{ userId, readAt, readPosition }]);
|
|
387
411
|
}, report));
|
|
388
412
|
add(() => this.client.onTyping(this.room, ({ userId, isTyping }) => {
|
|
389
413
|
if (!this.alive(generation) || !userId.trim() || userId === this.user) return;
|
|
@@ -426,9 +450,6 @@ var ConversationStore = class {
|
|
|
426
450
|
this.hydrations.set(message.id, job);
|
|
427
451
|
this.hydrationPool.queued.set(message.id, job);
|
|
428
452
|
this.drainHydration();
|
|
429
|
-
if (type === "insert" && !existing && message.senderId !== this.user && (this.options.markReadOnReceive ?? true)) {
|
|
430
|
-
void this.markRead();
|
|
431
|
-
}
|
|
432
453
|
}
|
|
433
454
|
record(message, insert, revision, complete) {
|
|
434
455
|
const existing = this.state.messages.find((item) => item.id === message.id);
|
|
@@ -438,7 +459,9 @@ var ConversationStore = class {
|
|
|
438
459
|
this.confirmSend(message);
|
|
439
460
|
}
|
|
440
461
|
this.changes.set(message.id, { revision, message, insert, complete });
|
|
441
|
-
if (existing
|
|
462
|
+
if (!existing && !(insert && hasContent(message))) return;
|
|
463
|
+
this.patch({ messages: mergeMessages(this.state.messages, [message]) });
|
|
464
|
+
if (!existing && message.senderId !== this.user && (this.options.markReadOnReceive ?? true)) void this.acknowledge(true);
|
|
442
465
|
}
|
|
443
466
|
confirmSend(message) {
|
|
444
467
|
const send = this.activeSend;
|
|
@@ -450,11 +473,19 @@ var ConversationStore = class {
|
|
|
450
473
|
return this.alive(job.generation) && !this.deleted.has(job.message.id) && this.hydrations.get(job.message.id) === job;
|
|
451
474
|
}
|
|
452
475
|
removeMessage(id) {
|
|
476
|
+
this.forget(id);
|
|
477
|
+
this.patch({ messages: this.state.messages.filter((message) => message.id !== id) });
|
|
478
|
+
}
|
|
479
|
+
/** Tombstone a row learned to be gone; a removed acknowledgement target is re-resolved from what remains. */
|
|
480
|
+
forget(id) {
|
|
453
481
|
this.deleted.add(id);
|
|
454
482
|
this.changes.delete(id);
|
|
455
483
|
this.hydrations.delete(id);
|
|
456
484
|
this.hydrationPool.queued.delete(id);
|
|
457
|
-
|
|
485
|
+
const ack = this.ack;
|
|
486
|
+
if (ack.target !== id && ack.acknowledged?.id !== id) return;
|
|
487
|
+
ack.unacknowledgeable.add(id);
|
|
488
|
+
if (ack.target === id) ack.followUp = true;
|
|
458
489
|
}
|
|
459
490
|
drainHydration() {
|
|
460
491
|
const pool = this.hydrationPool;
|
|
@@ -491,13 +522,23 @@ var ConversationStore = class {
|
|
|
491
522
|
});
|
|
492
523
|
}
|
|
493
524
|
}
|
|
525
|
+
/** Both maps only ever advance: acknowledgement times by time, positions by (createdAt, id). Participants and
|
|
526
|
+
* read events are the only sources; the local user's own read is never written from the device clock.
|
|
527
|
+
*/
|
|
494
528
|
mergeReads(entries) {
|
|
495
|
-
const
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
529
|
+
const readAt = new Map(this.state.readAtByUserId);
|
|
530
|
+
const positions = new Map(this.state.readPositionByUserId);
|
|
531
|
+
for (const entry of entries) {
|
|
532
|
+
const { userId, readPosition } = entry;
|
|
533
|
+
if (!userId.trim()) continue;
|
|
534
|
+
if (entry.readAt && Number.isFinite(entry.readAt.getTime()) && entry.readAt.getTime() > (readAt.get(userId)?.getTime() ?? -Infinity)) {
|
|
535
|
+
readAt.set(userId, entry.readAt);
|
|
536
|
+
}
|
|
537
|
+
if (!readPosition || typeof readPosition.messageId !== "string" || !readPosition.messageId.trim() || !(readPosition.createdAt instanceof Date) || !Number.isFinite(readPosition.createdAt.getTime())) continue;
|
|
538
|
+
const current = positions.get(userId);
|
|
539
|
+
if (!current || compare(positionCursor(readPosition), positionCursor(current)) > 0) positions.set(userId, readPosition);
|
|
499
540
|
}
|
|
500
|
-
this.patch({ readAtByUserId:
|
|
541
|
+
this.patch({ readAtByUserId: readAt, readPositionByUserId: positions });
|
|
501
542
|
}
|
|
502
543
|
validatePage(page, before) {
|
|
503
544
|
if (page.length > this.pageSize) throw new Error("Message page exceeds the requested limit");
|
|
@@ -554,9 +595,9 @@ var ConversationStore = class {
|
|
|
554
595
|
this.validatePage(page);
|
|
555
596
|
this.cursor = page.at(-1);
|
|
556
597
|
this.patch({ conversation, messages: this.overlay(page, revision), hasOlderMessages: page.length === this.pageSize });
|
|
557
|
-
this.mergeReads(conversation.participants.
|
|
598
|
+
this.mergeReads(conversation.participants.map(readEntry));
|
|
558
599
|
this.prune(revision);
|
|
559
|
-
if (this.options.markReadOnLoad ?? true) await this.
|
|
600
|
+
if (this.options.markReadOnLoad ?? true) await this.acknowledge(true);
|
|
560
601
|
} catch (cause) {
|
|
561
602
|
this.fail(cause, generation, true);
|
|
562
603
|
} finally {
|
|
@@ -612,14 +653,9 @@ var ConversationStore = class {
|
|
|
612
653
|
const reconciled = this.overlay(rows, revision);
|
|
613
654
|
const survivingIds = new Set(reconciled.map((message) => message.id));
|
|
614
655
|
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));
|
|
615
|
-
for (const id of known) if (!survivingIds.has(id))
|
|
616
|
-
this.deleted.add(id);
|
|
617
|
-
this.changes.delete(id);
|
|
618
|
-
this.hydrations.delete(id);
|
|
619
|
-
this.hydrationPool.queued.delete(id);
|
|
620
|
-
}
|
|
656
|
+
for (const id of known) if (!survivingIds.has(id)) this.forget(id);
|
|
621
657
|
this.patch({ conversation, messages: reconciled, hasOlderMessages: hasOlder });
|
|
622
|
-
this.mergeReads(conversation.participants.
|
|
658
|
+
this.mergeReads(conversation.participants.map(readEntry));
|
|
623
659
|
this.prune(revision);
|
|
624
660
|
} catch (cause) {
|
|
625
661
|
this.fail(cause, generation, true);
|
|
@@ -654,15 +690,68 @@ var ConversationStore = class {
|
|
|
654
690
|
}
|
|
655
691
|
}
|
|
656
692
|
};
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
693
|
+
/** Acknowledge through the newest rendered row now, regardless of visibility; no request without a target. */
|
|
694
|
+
markRead = () => this.alive() ? this.acknowledge(false) : Promise.resolve();
|
|
695
|
+
/** Automatic acknowledgements wait while hidden and are re-issued (once) on becoming visible. */
|
|
696
|
+
setVisible = (visible) => {
|
|
697
|
+
this.visible = visible;
|
|
698
|
+
if (!visible || !this.ack.suppressed || !this.alive()) return;
|
|
699
|
+
this.ack.suppressed = false;
|
|
700
|
+
void this.acknowledge(true);
|
|
701
|
+
};
|
|
702
|
+
/** The newest non-pending rendered row by (createdAt, id), never by list index and never a raw realtime
|
|
703
|
+
* row; rows the server does not know are skipped, and nothing at or before the accepted target is re-sent.
|
|
704
|
+
*/
|
|
705
|
+
ackTarget(ack) {
|
|
706
|
+
let target;
|
|
707
|
+
for (const message of this.state.messages) {
|
|
708
|
+
if (isConvoKitPendingMessage(message) || ack.unacknowledgeable.has(message.id)) continue;
|
|
709
|
+
if (!target || compare(message, target) > 0) target = { createdAt: message.createdAt, id: message.id };
|
|
710
|
+
}
|
|
711
|
+
return target && (!ack.acknowledged || compare(target, ack.acknowledged) > 0) ? target : void 0;
|
|
712
|
+
}
|
|
713
|
+
/** Resolves when the request this call issued or joined settles; a follow-up is issued, not awaited. */
|
|
714
|
+
acknowledge(automatic) {
|
|
715
|
+
const ack = this.ack;
|
|
716
|
+
if (automatic && !this.visible) {
|
|
717
|
+
ack.suppressed = true;
|
|
718
|
+
return Promise.resolve();
|
|
719
|
+
}
|
|
720
|
+
if (ack.inFlight) {
|
|
721
|
+
ack.followUp = true;
|
|
722
|
+
return ack.inFlight;
|
|
723
|
+
}
|
|
724
|
+
return this.issue(ack, this.generation) ?? Promise.resolve();
|
|
725
|
+
}
|
|
726
|
+
issue(ack, generation) {
|
|
727
|
+
ack.followUp = false;
|
|
728
|
+
const target = this.ackTarget(ack);
|
|
729
|
+
if (!target) return void 0;
|
|
730
|
+
ack.target = target.id;
|
|
731
|
+
ack.inFlight = this.send(ack, generation, target).finally(() => {
|
|
732
|
+
ack.inFlight = void 0;
|
|
733
|
+
ack.target = void 0;
|
|
734
|
+
if (!this.alive(generation) || !ack.followUp) return;
|
|
735
|
+
if (!this.visible) {
|
|
736
|
+
ack.followUp = false;
|
|
737
|
+
ack.suppressed = true;
|
|
738
|
+
} else this.issue(ack, generation);
|
|
739
|
+
});
|
|
740
|
+
return ack.inFlight;
|
|
741
|
+
}
|
|
742
|
+
async send(ack, generation, target) {
|
|
660
743
|
try {
|
|
661
|
-
await this.client.markConversationRead(this.room);
|
|
744
|
+
await this.client.markConversationRead(this.room, { throughMessageId: target.id });
|
|
745
|
+
if (!this.alive(generation)) return;
|
|
746
|
+
if (!ack.acknowledged || compare(target, ack.acknowledged) > 0) ack.acknowledged = target;
|
|
662
747
|
} catch (cause) {
|
|
663
|
-
this.
|
|
748
|
+
if (!this.alive(generation)) return;
|
|
749
|
+
if (isTargetMiss(cause)) {
|
|
750
|
+
ack.unacknowledgeable.add(target.id);
|
|
751
|
+
ack.followUp = true;
|
|
752
|
+
} else this.fail(cause, generation);
|
|
664
753
|
}
|
|
665
|
-
}
|
|
754
|
+
}
|
|
666
755
|
updateTyping = async (isTyping) => {
|
|
667
756
|
if (!this.alive()) return;
|
|
668
757
|
const generation = this.generation;
|
|
@@ -691,7 +780,7 @@ var ConversationStore = class {
|
|
|
691
780
|
const generation = this.generation;
|
|
692
781
|
const revision = this.revision;
|
|
693
782
|
this.sendRevision = revision;
|
|
694
|
-
const clientMessageId = (0,
|
|
783
|
+
const clientMessageId = (0, import_sdk2.createClientMessageId)();
|
|
695
784
|
const pendingId = `convokit-pending-${clientMessageId}`;
|
|
696
785
|
const pending = {
|
|
697
786
|
id: pendingId,
|
|
@@ -745,7 +834,7 @@ var ConversationStore = class {
|
|
|
745
834
|
}
|
|
746
835
|
}
|
|
747
836
|
};
|
|
748
|
-
readerIdsFor = (message) => readerIdsFor(message, this.state.readAtByUserId);
|
|
837
|
+
readerIdsFor = (message) => readerIdsFor(message, this.state.readAtByUserId, this.state.readPositionByUserId);
|
|
749
838
|
};
|
|
750
839
|
|
|
751
840
|
// src/composables/use-conversation.ts
|
|
@@ -758,6 +847,7 @@ function useConversation(options) {
|
|
|
758
847
|
let store = createStore();
|
|
759
848
|
const snapshot = (0, import_vue3.shallowRef)(store.getSnapshot());
|
|
760
849
|
let unsubscribe;
|
|
850
|
+
let visible = true;
|
|
761
851
|
const stop = (0, import_vue3.watch)(
|
|
762
852
|
() => [(0, import_vue3.toValue)(options.client), (0, import_vue3.toValue)(options.client).sessionIdentity, (0, import_vue3.toValue)(options.conversationId)],
|
|
763
853
|
() => {
|
|
@@ -768,6 +858,7 @@ function useConversation(options) {
|
|
|
768
858
|
unsubscribe = store.subscribe(() => {
|
|
769
859
|
snapshot.value = store.getSnapshot();
|
|
770
860
|
});
|
|
861
|
+
store.setVisible(visible);
|
|
771
862
|
store.start(options.autoLoad ?? true);
|
|
772
863
|
},
|
|
773
864
|
{ immediate: true, flush: "sync" }
|
|
@@ -787,6 +878,7 @@ function useConversation(options) {
|
|
|
787
878
|
messages: field("messages"),
|
|
788
879
|
typingUserIds: field("typingUserIds"),
|
|
789
880
|
readAtByUserId: field("readAtByUserId"),
|
|
881
|
+
readPositionByUserId: field("readPositionByUserId"),
|
|
790
882
|
isInitialLoading: field("isInitialLoading"),
|
|
791
883
|
isLoadingOlder: field("isLoadingOlder"),
|
|
792
884
|
isReconciling: field("isReconciling"),
|
|
@@ -802,6 +894,10 @@ function useConversation(options) {
|
|
|
802
894
|
sendMessage: (input) => store.sendMessage(input),
|
|
803
895
|
markRead: () => store.markRead(),
|
|
804
896
|
updateTyping: (isTyping) => store.updateTyping(isTyping),
|
|
897
|
+
setVisible: (value) => {
|
|
898
|
+
visible = value;
|
|
899
|
+
store.setVisible(value);
|
|
900
|
+
},
|
|
805
901
|
dispose
|
|
806
902
|
};
|
|
807
903
|
}
|
|
@@ -857,6 +953,7 @@ var MessageListView = (0, import_vue5.defineComponent)({
|
|
|
857
953
|
messages: { type: Array, required: true },
|
|
858
954
|
currentUserId: { type: String, required: true },
|
|
859
955
|
readAtByUserId: { type: Object, default: () => /* @__PURE__ */ new Map() },
|
|
956
|
+
readPositionByUserId: { type: Object, default: () => /* @__PURE__ */ new Map() },
|
|
860
957
|
readersResolver: { type: Function, default: void 0 },
|
|
861
958
|
onLoadOlder: { type: Function, default: void 0 },
|
|
862
959
|
hasOlderMessages: { type: Boolean, default: false },
|
|
@@ -916,7 +1013,7 @@ var MessageListView = (0, import_vue5.defineComponent)({
|
|
|
916
1013
|
const isCurrentUser = message.senderId === props.currentUserId;
|
|
917
1014
|
const sender = participants.value.get(message.senderId);
|
|
918
1015
|
const isPending = isConvoKitPendingMessage(message);
|
|
919
|
-
const readerIds = isPending ? /* @__PURE__ */ new Set() : props.readersResolver ? props.readersResolver(message) : readerIdsFor(message, props.readAtByUserId);
|
|
1016
|
+
const readerIds = isPending ? /* @__PURE__ */ new Set() : props.readersResolver ? props.readersResolver(message) : readerIdsFor(message, props.readAtByUserId, props.readPositionByUserId);
|
|
920
1017
|
const slotProps = { message, chronologicalIndex: index, isCurrentUser, sender, readerIds };
|
|
921
1018
|
const custom = slots.message?.(slotProps);
|
|
922
1019
|
if (custom) return (0, import_vue5.h)("div", { key: message.id, role: "listitem" }, custom);
|
|
@@ -1015,8 +1112,8 @@ var MessageListView = (0, import_vue5.defineComponent)({
|
|
|
1015
1112
|
};
|
|
1016
1113
|
}
|
|
1017
1114
|
});
|
|
1018
|
-
function defaultReadersResolver(readAtByUserId) {
|
|
1019
|
-
return (message) => readerIdsFor(message, readAtByUserId);
|
|
1115
|
+
function defaultReadersResolver(readAtByUserId, readPositionByUserId = /* @__PURE__ */ new Map()) {
|
|
1116
|
+
return (message) => readerIdsFor(message, readAtByUserId, readPositionByUserId);
|
|
1020
1117
|
}
|
|
1021
1118
|
|
|
1022
1119
|
// src/components/conversation.ts
|
|
@@ -1034,6 +1131,7 @@ var viewProps = {
|
|
|
1034
1131
|
onSendMessage: { type: Function, required: true },
|
|
1035
1132
|
typingUserIds: { type: Object, default: () => /* @__PURE__ */ new Set() },
|
|
1036
1133
|
readAtByUserId: { type: Object, default: () => /* @__PURE__ */ new Map() },
|
|
1134
|
+
readPositionByUserId: { type: Object, default: () => /* @__PURE__ */ new Map() },
|
|
1037
1135
|
readersResolver: { type: Function, default: void 0 },
|
|
1038
1136
|
onBack: { type: Function, default: void 0 },
|
|
1039
1137
|
onRefresh: { type: Function, default: void 0 },
|
|
@@ -1262,6 +1360,7 @@ var ConversationView = (0, import_vue7.defineComponent)({
|
|
|
1262
1360
|
messages: props.messages,
|
|
1263
1361
|
currentUserId: props.currentUserId,
|
|
1264
1362
|
readAtByUserId: props.readAtByUserId,
|
|
1363
|
+
readPositionByUserId: props.readPositionByUserId,
|
|
1265
1364
|
...props.readersResolver ? { readersResolver: props.readersResolver } : {},
|
|
1266
1365
|
...props.onLoadOlder ? { onLoadOlder: loadOlder } : {},
|
|
1267
1366
|
hasOlderMessages: props.hasOlderMessages,
|
|
@@ -1324,6 +1423,12 @@ var Conversation = (0, import_vue7.defineComponent)({
|
|
|
1324
1423
|
(0, import_vue7.watchEffect)(() => {
|
|
1325
1424
|
emit("controller-change", controller);
|
|
1326
1425
|
});
|
|
1426
|
+
if (typeof document !== "undefined") {
|
|
1427
|
+
const syncVisibility = () => controller.setVisible(document.visibilityState !== "hidden");
|
|
1428
|
+
syncVisibility();
|
|
1429
|
+
document.addEventListener("visibilitychange", syncVisibility);
|
|
1430
|
+
(0, import_vue7.onBeforeUnmount)(() => document.removeEventListener("visibilitychange", syncVisibility));
|
|
1431
|
+
}
|
|
1327
1432
|
return () => {
|
|
1328
1433
|
const loadedConversation = controller.conversation.value;
|
|
1329
1434
|
if (!loadedConversation) {
|
|
@@ -1358,6 +1463,7 @@ var Conversation = (0, import_vue7.defineComponent)({
|
|
|
1358
1463
|
onSendMessage: _onSendMessage,
|
|
1359
1464
|
typingUserIds: _typingUserIds,
|
|
1360
1465
|
readAtByUserId: _readAtByUserId,
|
|
1466
|
+
readPositionByUserId: _readPositionByUserId,
|
|
1361
1467
|
onRefresh: _onRefresh,
|
|
1362
1468
|
onLoadOlder: _onLoadOlder,
|
|
1363
1469
|
onTypingChange: _onTypingChange,
|
|
@@ -1380,6 +1486,7 @@ var Conversation = (0, import_vue7.defineComponent)({
|
|
|
1380
1486
|
},
|
|
1381
1487
|
typingUserIds: controller.typingUserIds.value,
|
|
1382
1488
|
readAtByUserId: controller.readAtByUserId.value,
|
|
1489
|
+
readPositionByUserId: controller.readPositionByUserId.value,
|
|
1383
1490
|
onRefresh: controller.refresh,
|
|
1384
1491
|
onLoadOlder: controller.loadOlderMessages,
|
|
1385
1492
|
onTypingChange: controller.updateTyping,
|