@ai-matrx/messaging 0.15.14 → 0.15.15
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 +6 -0
- package/dist/index.cjs +446 -101
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +90 -50
- package/dist/index.d.ts +90 -50
- package/dist/index.js +446 -101
- package/dist/index.js.map +1 -1
- package/dist/react.cjs +447 -103
- package/dist/react.cjs.map +1 -1
- package/dist/react.d.cts +90 -50
- package/dist/react.d.ts +90 -50
- package/dist/react.js +447 -103
- package/dist/react.js.map +1 -1
- package/package.json +4 -4
package/dist/index.cjs
CHANGED
|
@@ -70,7 +70,8 @@ __export(src_exports, {
|
|
|
70
70
|
splitText: () => splitText,
|
|
71
71
|
summarizeText: () => summarizeText,
|
|
72
72
|
toMessagingArchiveFilter: () => toMessagingArchiveFilter,
|
|
73
|
-
unreadCutoff: () => unreadCutoff
|
|
73
|
+
unreadCutoff: () => unreadCutoff,
|
|
74
|
+
writeNotApplied: () => writeNotApplied
|
|
74
75
|
});
|
|
75
76
|
module.exports = __toCommonJS(src_exports);
|
|
76
77
|
|
|
@@ -308,6 +309,13 @@ function invalidResponse(operation, detail) {
|
|
|
308
309
|
"The database returned a shape this package does not accept. Rendering it would put a lie on the screen, so it is refused here. Fix the RPC's return contract."
|
|
309
310
|
);
|
|
310
311
|
}
|
|
312
|
+
function writeNotApplied(operation, detail) {
|
|
313
|
+
return new MessagingError(
|
|
314
|
+
"write-not-applied",
|
|
315
|
+
`${operation}: ${detail}`,
|
|
316
|
+
"The intended live row was not changed. Re-read it before retrying; the row may be gone, soft-deleted, or hidden from this session by database authorization."
|
|
317
|
+
);
|
|
318
|
+
}
|
|
311
319
|
|
|
312
320
|
// src/core/references.ts
|
|
313
321
|
var FENCE = /```matrx\s*\n([\s\S]*?)\n?```/g;
|
|
@@ -1087,6 +1095,7 @@ function insertOrdered(messages, message) {
|
|
|
1087
1095
|
}
|
|
1088
1096
|
function createMessagingStore() {
|
|
1089
1097
|
let conversations = [];
|
|
1098
|
+
let conversationSummaries = /* @__PURE__ */ new Map();
|
|
1090
1099
|
let hasMoreConversations = false;
|
|
1091
1100
|
let hasLoadedConversations = false;
|
|
1092
1101
|
let archiveFilter = DEFAULT_MESSAGING_ARCHIVE_FILTER;
|
|
@@ -1095,12 +1104,15 @@ function createMessagingStore() {
|
|
|
1095
1104
|
let activeConversationId = null;
|
|
1096
1105
|
let conversationReadFailures = /* @__PURE__ */ new Map();
|
|
1097
1106
|
let conversationsLoadError = null;
|
|
1107
|
+
const unreadRevisions = /* @__PURE__ */ new Map();
|
|
1108
|
+
const confirmedReadRevisions = /* @__PURE__ */ new Map();
|
|
1098
1109
|
const listeners = /* @__PURE__ */ new Set();
|
|
1099
1110
|
let cached = null;
|
|
1100
1111
|
function snapshot() {
|
|
1101
1112
|
if (cached !== null) return cached;
|
|
1102
1113
|
cached = {
|
|
1103
1114
|
conversations,
|
|
1115
|
+
conversationSummaries,
|
|
1104
1116
|
hasMoreConversations,
|
|
1105
1117
|
hasLoadedConversations,
|
|
1106
1118
|
threads,
|
|
@@ -1119,11 +1131,33 @@ function createMessagingStore() {
|
|
|
1119
1131
|
listeners.forEach((listener) => listener(next));
|
|
1120
1132
|
}
|
|
1121
1133
|
function normalizeConversations(items) {
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1134
|
+
return [...items].sort((a, b) => a.sortAt < b.sortAt ? 1 : a.sortAt > b.sortAt ? -1 : 0);
|
|
1135
|
+
}
|
|
1136
|
+
function replaceConversations(next) {
|
|
1137
|
+
const summaries = new Map(conversationSummaries);
|
|
1138
|
+
next.forEach((item) => {
|
|
1139
|
+
const id = item.conversation.id;
|
|
1140
|
+
if (summaries.get(id)?.unreadCount !== item.unreadCount) {
|
|
1141
|
+
unreadRevisions.set(id, (unreadRevisions.get(id) ?? 0) + 1);
|
|
1142
|
+
}
|
|
1143
|
+
summaries.set(id, item);
|
|
1144
|
+
});
|
|
1145
|
+
conversationSummaries = summaries;
|
|
1146
|
+
conversations = next;
|
|
1147
|
+
}
|
|
1148
|
+
function replaceOneSummary(id, update) {
|
|
1149
|
+
const held = conversationSummaries.get(id);
|
|
1150
|
+
if (held === void 0) return;
|
|
1151
|
+
const replacement = update(held);
|
|
1152
|
+
if (held.unreadCount !== replacement.unreadCount) {
|
|
1153
|
+
unreadRevisions.set(id, (unreadRevisions.get(id) ?? 0) + 1);
|
|
1154
|
+
}
|
|
1155
|
+
const summaries = new Map(conversationSummaries);
|
|
1156
|
+
summaries.set(id, replacement);
|
|
1157
|
+
conversationSummaries = summaries;
|
|
1158
|
+
conversations = conversations.map(
|
|
1159
|
+
(item) => item.conversation.id === id ? replacement : item
|
|
1125
1160
|
);
|
|
1126
|
-
return [...zeroed].sort((a, b) => a.sortAt < b.sortAt ? 1 : a.sortAt > b.sortAt ? -1 : 0);
|
|
1127
1161
|
}
|
|
1128
1162
|
function threadFor(id) {
|
|
1129
1163
|
return threads.get(id) ?? {
|
|
@@ -1135,7 +1169,9 @@ function createMessagingStore() {
|
|
|
1135
1169
|
}
|
|
1136
1170
|
function writeThread(thread) {
|
|
1137
1171
|
const next = new Map(threads);
|
|
1138
|
-
const latest = thread.messages.
|
|
1172
|
+
const latest = [...thread.messages].reverse().find(
|
|
1173
|
+
(message) => message.deliveryState !== "sending" && message.deliveryState !== "failed" && !message.id.startsWith("optimistic:")
|
|
1174
|
+
);
|
|
1139
1175
|
next.set(thread.conversationId, {
|
|
1140
1176
|
...thread,
|
|
1141
1177
|
latestAt: latest?.createdAt ?? thread.latestAt
|
|
@@ -1151,7 +1187,7 @@ function createMessagingStore() {
|
|
|
1151
1187
|
};
|
|
1152
1188
|
},
|
|
1153
1189
|
setConversations(items, hasMore) {
|
|
1154
|
-
|
|
1190
|
+
replaceConversations(normalizeConversations(items));
|
|
1155
1191
|
hasMoreConversations = hasMore;
|
|
1156
1192
|
hasLoadedConversations = true;
|
|
1157
1193
|
conversationsLoadError = null;
|
|
@@ -1165,7 +1201,7 @@ function createMessagingStore() {
|
|
|
1165
1201
|
setArchiveFilter(next) {
|
|
1166
1202
|
if (next === archiveFilter) return;
|
|
1167
1203
|
archiveFilter = next;
|
|
1168
|
-
|
|
1204
|
+
replaceConversations([]);
|
|
1169
1205
|
hasMoreConversations = false;
|
|
1170
1206
|
hasLoadedConversations = false;
|
|
1171
1207
|
emit();
|
|
@@ -1177,18 +1213,34 @@ function createMessagingStore() {
|
|
|
1177
1213
|
appendConversations(items, hasMore) {
|
|
1178
1214
|
const byId = new Map(conversations.map((item) => [item.conversation.id, item]));
|
|
1179
1215
|
items.forEach((item) => byId.set(item.conversation.id, item));
|
|
1180
|
-
|
|
1216
|
+
replaceConversations(normalizeConversations([...byId.values()]));
|
|
1181
1217
|
hasMoreConversations = hasMore;
|
|
1182
1218
|
emit();
|
|
1183
1219
|
},
|
|
1184
1220
|
upsertConversation(item) {
|
|
1185
1221
|
const byId = new Map(conversations.map((entry) => [entry.conversation.id, entry]));
|
|
1186
1222
|
byId.set(item.conversation.id, item);
|
|
1187
|
-
|
|
1223
|
+
replaceConversations(normalizeConversations([...byId.values()]));
|
|
1224
|
+
emit();
|
|
1225
|
+
},
|
|
1226
|
+
setConversationSummary(item) {
|
|
1227
|
+
const id = item.conversation.id;
|
|
1228
|
+
const held = conversationSummaries.get(id);
|
|
1229
|
+
if (held?.unreadCount !== item.unreadCount) {
|
|
1230
|
+
unreadRevisions.set(id, (unreadRevisions.get(id) ?? 0) + 1);
|
|
1231
|
+
}
|
|
1232
|
+
const summaries = new Map(conversationSummaries);
|
|
1233
|
+
summaries.set(id, item);
|
|
1234
|
+
conversationSummaries = summaries;
|
|
1188
1235
|
emit();
|
|
1189
1236
|
},
|
|
1190
1237
|
removeConversation(id) {
|
|
1191
|
-
conversations
|
|
1238
|
+
replaceConversations(conversations.filter((item) => item.conversation.id !== id));
|
|
1239
|
+
const summaries = new Map(conversationSummaries);
|
|
1240
|
+
summaries.delete(id);
|
|
1241
|
+
conversationSummaries = summaries;
|
|
1242
|
+
unreadRevisions.delete(id);
|
|
1243
|
+
confirmedReadRevisions.delete(id);
|
|
1192
1244
|
const next = new Map(threads);
|
|
1193
1245
|
next.delete(id);
|
|
1194
1246
|
threads = next;
|
|
@@ -1196,7 +1248,6 @@ function createMessagingStore() {
|
|
|
1196
1248
|
},
|
|
1197
1249
|
setActiveConversation(id) {
|
|
1198
1250
|
activeConversationId = id;
|
|
1199
|
-
conversations = normalizeConversations(conversations);
|
|
1200
1251
|
emit();
|
|
1201
1252
|
},
|
|
1202
1253
|
setConversationReadFailure(id, failure) {
|
|
@@ -1216,6 +1267,13 @@ function createMessagingStore() {
|
|
|
1216
1267
|
});
|
|
1217
1268
|
emit();
|
|
1218
1269
|
},
|
|
1270
|
+
clearThread(id) {
|
|
1271
|
+
if (!threads.has(id)) return;
|
|
1272
|
+
const next = new Map(threads);
|
|
1273
|
+
next.delete(id);
|
|
1274
|
+
threads = next;
|
|
1275
|
+
emit();
|
|
1276
|
+
},
|
|
1219
1277
|
prependOlder(id, messages, hasMoreOlder) {
|
|
1220
1278
|
const thread = threadFor(id);
|
|
1221
1279
|
const known = new Set(thread.messages.map((message) => message.id));
|
|
@@ -1268,40 +1326,48 @@ function createMessagingStore() {
|
|
|
1268
1326
|
emit();
|
|
1269
1327
|
},
|
|
1270
1328
|
applyLastMessage(message, args = {}) {
|
|
1271
|
-
const existing =
|
|
1272
|
-
(item) => item.conversation.id === message.conversationId
|
|
1273
|
-
);
|
|
1329
|
+
const existing = conversationSummaries.get(message.conversationId);
|
|
1274
1330
|
if (existing === void 0) return;
|
|
1275
1331
|
if (message.deliveryState === "sending" || message.deletedAt !== null) return;
|
|
1276
1332
|
if (existing.lastMessageAt !== null && message.createdAt < existing.lastMessageAt) return;
|
|
1277
|
-
const
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
unreadCount: shouldCount ? item.unreadCount + 1 : item.unreadCount
|
|
1288
|
-
} : item
|
|
1289
|
-
)
|
|
1290
|
-
);
|
|
1333
|
+
const shouldCount = args.incrementUnread === true;
|
|
1334
|
+
replaceOneSummary(message.conversationId, (item) => ({
|
|
1335
|
+
...item,
|
|
1336
|
+
lastMessageContent: message.content,
|
|
1337
|
+
lastMessageSenderId: message.senderId,
|
|
1338
|
+
lastMessageAt: message.createdAt,
|
|
1339
|
+
sortAt: message.createdAt,
|
|
1340
|
+
unreadCount: shouldCount ? item.unreadCount + 1 : item.unreadCount
|
|
1341
|
+
}));
|
|
1342
|
+
conversations = normalizeConversations(conversations);
|
|
1291
1343
|
emit();
|
|
1292
1344
|
},
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1345
|
+
captureConversationRead(id, at) {
|
|
1346
|
+
const item = conversationSummaries.get(id);
|
|
1347
|
+
return {
|
|
1348
|
+
at,
|
|
1349
|
+
unreadCount: item?.unreadCount ?? 0,
|
|
1350
|
+
revision: unreadRevisions.get(id) ?? 0
|
|
1351
|
+
};
|
|
1352
|
+
},
|
|
1353
|
+
markConversationRead(id, cutoff, canonicalUnreadCount = 0) {
|
|
1354
|
+
if (cutoff.revision <= (confirmedReadRevisions.get(id) ?? -1)) return;
|
|
1355
|
+
confirmedReadRevisions.set(id, cutoff.revision);
|
|
1356
|
+
replaceOneSummary(id, (item) => ({
|
|
1357
|
+
...item,
|
|
1358
|
+
// The repository re-counts after persisting the exact server cutoff.
|
|
1359
|
+
// If realtime advanced local state while that response was in flight,
|
|
1360
|
+
// retain at least the arrivals beyond the captured baseline.
|
|
1361
|
+
unreadCount: Math.max(
|
|
1362
|
+
0,
|
|
1363
|
+
canonicalUnreadCount,
|
|
1364
|
+
item.unreadCount - cutoff.unreadCount
|
|
1365
|
+
)
|
|
1366
|
+
}));
|
|
1297
1367
|
emit();
|
|
1298
1368
|
},
|
|
1299
1369
|
setUnreadCount(id, count) {
|
|
1300
|
-
|
|
1301
|
-
conversations.map(
|
|
1302
|
-
(item) => item.conversation.id === id ? { ...item, unreadCount: Math.max(0, count) } : item
|
|
1303
|
-
)
|
|
1304
|
-
);
|
|
1370
|
+
replaceOneSummary(id, (item) => ({ ...item, unreadCount: Math.max(0, count) }));
|
|
1305
1371
|
emit();
|
|
1306
1372
|
}
|
|
1307
1373
|
};
|
|
@@ -1338,21 +1404,57 @@ function createMessagingEngine(options) {
|
|
|
1338
1404
|
const conversationPageSize = options.conversationPageSize ?? 30;
|
|
1339
1405
|
const messagePageSize = options.messagePageSize ?? 50;
|
|
1340
1406
|
const openChannels = /* @__PURE__ */ new Map();
|
|
1407
|
+
const channelReadiness = /* @__PURE__ */ new Map();
|
|
1408
|
+
const settleChannelReadiness = /* @__PURE__ */ new Map();
|
|
1409
|
+
const readReceiptTails = /* @__PURE__ */ new Map();
|
|
1410
|
+
const persistedReadAt = /* @__PURE__ */ new Map();
|
|
1411
|
+
const initialReadDepth = /* @__PURE__ */ new Map();
|
|
1412
|
+
const initialReadGeneration = /* @__PURE__ */ new Map();
|
|
1413
|
+
const initialReadChanges = /* @__PURE__ */ new Map();
|
|
1341
1414
|
let inboxChannel = null;
|
|
1342
1415
|
let conversationCursor = null;
|
|
1343
1416
|
let disposed = false;
|
|
1344
1417
|
function report(event) {
|
|
1345
1418
|
options.onDiagnostic?.(event);
|
|
1346
1419
|
}
|
|
1347
|
-
function reportError(error, operation) {
|
|
1420
|
+
function reportError(error, operation, context = {}) {
|
|
1348
1421
|
const normalized = normalizeMessagingError(error, operation);
|
|
1349
1422
|
report({
|
|
1350
1423
|
// A missing session is a NORMAL lifecycle moment, not a red error. This
|
|
1351
1424
|
// one line is the cure for "909 captured errors in 0.6s".
|
|
1352
1425
|
level: normalized.code === "session-unavailable" ? "warn" : "error",
|
|
1353
1426
|
message: normalized.message,
|
|
1354
|
-
remedy: normalized.remedy
|
|
1427
|
+
remedy: normalized.remedy,
|
|
1428
|
+
code: normalized.code,
|
|
1429
|
+
operation,
|
|
1430
|
+
isRetryable: normalized.isRetryable,
|
|
1431
|
+
error: normalized,
|
|
1432
|
+
...context
|
|
1355
1433
|
});
|
|
1434
|
+
return normalized;
|
|
1435
|
+
}
|
|
1436
|
+
function persistRead(id, isLive = () => true) {
|
|
1437
|
+
const run = async () => {
|
|
1438
|
+
if (!isLive()) return;
|
|
1439
|
+
const at = store.snapshot().threads.get(id)?.latestAt ?? null;
|
|
1440
|
+
if (at === null || (persistedReadAt.get(id) ?? "") >= at) return;
|
|
1441
|
+
const cutoff = store.captureConversationRead(id, at);
|
|
1442
|
+
try {
|
|
1443
|
+
const canonicalUnreadCount = await repository.markRead(id, at);
|
|
1444
|
+
if (!isLive()) return;
|
|
1445
|
+
persistedReadAt.set(id, at);
|
|
1446
|
+
store.markConversationRead(id, cutoff, canonicalUnreadCount);
|
|
1447
|
+
} catch (error) {
|
|
1448
|
+
throw reportError(error, "markRead", { conversationId: id });
|
|
1449
|
+
}
|
|
1450
|
+
};
|
|
1451
|
+
const previous = readReceiptTails.get(id);
|
|
1452
|
+
const current = previous === void 0 ? run() : previous.catch(() => void 0).then(run);
|
|
1453
|
+
readReceiptTails.set(id, current);
|
|
1454
|
+
void current.finally(() => {
|
|
1455
|
+
if (readReceiptTails.get(id) === current) readReceiptTails.delete(id);
|
|
1456
|
+
}).catch(() => void 0);
|
|
1457
|
+
return current;
|
|
1356
1458
|
}
|
|
1357
1459
|
const outbox = createOutbox({
|
|
1358
1460
|
...options.outboxStorage !== void 0 ? { storage: options.outboxStorage } : {},
|
|
@@ -1437,6 +1539,137 @@ function createMessagingEngine(options) {
|
|
|
1437
1539
|
reportError(error, "backfillConversation");
|
|
1438
1540
|
}
|
|
1439
1541
|
}
|
|
1542
|
+
function applyLiveMessageChange(id, change) {
|
|
1543
|
+
if ((initialReadDepth.get(id) ?? 0) > 0) {
|
|
1544
|
+
const changes = initialReadChanges.get(id) ?? [];
|
|
1545
|
+
changes.push(change);
|
|
1546
|
+
initialReadChanges.set(id, changes);
|
|
1547
|
+
}
|
|
1548
|
+
if (change.kind === "delete") {
|
|
1549
|
+
store.removeMessage(id, change.messageId);
|
|
1550
|
+
} else {
|
|
1551
|
+
store.ingest(change.message);
|
|
1552
|
+
}
|
|
1553
|
+
}
|
|
1554
|
+
function beginInitialRead(id) {
|
|
1555
|
+
const generation = (initialReadGeneration.get(id) ?? 0) + 1;
|
|
1556
|
+
initialReadGeneration.set(id, generation);
|
|
1557
|
+
initialReadDepth.set(id, (initialReadDepth.get(id) ?? 0) + 1);
|
|
1558
|
+
initialReadChanges.set(id, initialReadChanges.get(id) ?? []);
|
|
1559
|
+
return generation;
|
|
1560
|
+
}
|
|
1561
|
+
function finishInitialRead(id) {
|
|
1562
|
+
const depth = (initialReadDepth.get(id) ?? 1) - 1;
|
|
1563
|
+
if (depth > 0) {
|
|
1564
|
+
initialReadDepth.set(id, depth);
|
|
1565
|
+
return;
|
|
1566
|
+
}
|
|
1567
|
+
initialReadDepth.delete(id);
|
|
1568
|
+
initialReadChanges.delete(id);
|
|
1569
|
+
}
|
|
1570
|
+
function openIsLive(id, generation, channel) {
|
|
1571
|
+
return !disposed && initialReadGeneration.get(id) === generation && channel !== null && openChannels.get(id) === channel;
|
|
1572
|
+
}
|
|
1573
|
+
function installInitialPage(id, generation, page) {
|
|
1574
|
+
if (initialReadGeneration.get(id) !== generation) return false;
|
|
1575
|
+
const optimistic = (store.snapshot().threads.get(id)?.messages ?? []).filter(
|
|
1576
|
+
(item) => item.deliveryState === "sending" || item.deliveryState === "failed"
|
|
1577
|
+
);
|
|
1578
|
+
const confirmedClientKeys = new Set(
|
|
1579
|
+
page.items.flatMap(
|
|
1580
|
+
(item) => item.clientMessageId === null ? [] : [item.clientMessageId]
|
|
1581
|
+
)
|
|
1582
|
+
);
|
|
1583
|
+
store.setThread(id, page.items, { hasMoreOlder: page.hasMore });
|
|
1584
|
+
for (const change of initialReadChanges.get(id) ?? []) {
|
|
1585
|
+
if (change.kind === "delete") {
|
|
1586
|
+
store.removeMessage(id, change.messageId);
|
|
1587
|
+
} else {
|
|
1588
|
+
store.ingest(change.message);
|
|
1589
|
+
}
|
|
1590
|
+
}
|
|
1591
|
+
store.ingestMany(
|
|
1592
|
+
optimistic.filter(
|
|
1593
|
+
(item) => item.clientMessageId === null || !confirmedClientKeys.has(item.clientMessageId)
|
|
1594
|
+
)
|
|
1595
|
+
);
|
|
1596
|
+
return true;
|
|
1597
|
+
}
|
|
1598
|
+
function ensureConversationChannel(id) {
|
|
1599
|
+
const existing = openChannels.get(id);
|
|
1600
|
+
if (existing !== void 0) {
|
|
1601
|
+
return channelReadiness.get(id) ?? Promise.resolve(existing.status() === "connected");
|
|
1602
|
+
}
|
|
1603
|
+
let markReady;
|
|
1604
|
+
const ready = new Promise((resolve) => {
|
|
1605
|
+
markReady = resolve;
|
|
1606
|
+
});
|
|
1607
|
+
const handle = manager.open({
|
|
1608
|
+
topic: conversationTopic(id),
|
|
1609
|
+
// Presence callbacks are JOIN-TIME in supabase-js. Declare the read-only
|
|
1610
|
+
// room shape on the first holder so later holders do not create a gap.
|
|
1611
|
+
presence: { publish: false, state: {} },
|
|
1612
|
+
postgresChanges: [
|
|
1613
|
+
{
|
|
1614
|
+
event: "*",
|
|
1615
|
+
schema: "communication",
|
|
1616
|
+
table: "dm_messages",
|
|
1617
|
+
filter: `conversation_id=eq.${id}`,
|
|
1618
|
+
rowId: (row) => typeof row["id"] === "string" ? row["id"] : void 0,
|
|
1619
|
+
fingerprint: (row) => typeof row["content"] === "string" ? row["content"] : void 0,
|
|
1620
|
+
updatedAtField: "updated_at",
|
|
1621
|
+
updatedByField: "updated_by",
|
|
1622
|
+
onChange: ({ payload, row }) => {
|
|
1623
|
+
if (row === null) return;
|
|
1624
|
+
if (payload?.eventType === "DELETE" || typeof row["deleted_at"] === "string" && row["deleted_at"].length > 0) {
|
|
1625
|
+
const rawId = row["id"];
|
|
1626
|
+
if (typeof rawId !== "string" || rawId.length === 0) {
|
|
1627
|
+
reportError(
|
|
1628
|
+
new Error("A message deletion payload carried no message id."),
|
|
1629
|
+
"conversationMessageDelete",
|
|
1630
|
+
{ conversationId: id }
|
|
1631
|
+
);
|
|
1632
|
+
return;
|
|
1633
|
+
}
|
|
1634
|
+
applyLiveMessageChange(id, {
|
|
1635
|
+
kind: "delete",
|
|
1636
|
+
messageId: asMessageId(rawId)
|
|
1637
|
+
});
|
|
1638
|
+
return;
|
|
1639
|
+
}
|
|
1640
|
+
const message = projectMessage(row, identity.organizationId);
|
|
1641
|
+
applyLiveMessageChange(id, { kind: "upsert", message });
|
|
1642
|
+
}
|
|
1643
|
+
}
|
|
1644
|
+
],
|
|
1645
|
+
broadcast: [
|
|
1646
|
+
{
|
|
1647
|
+
event: MESSAGING_EVENTS.message,
|
|
1648
|
+
onMessage: ({ data }) => {
|
|
1649
|
+
try {
|
|
1650
|
+
const message = projectBroadcastMessage(data);
|
|
1651
|
+
applyLiveMessageChange(id, { kind: "upsert", message });
|
|
1652
|
+
} catch (error) {
|
|
1653
|
+
reportError(error, "broadcastMessage");
|
|
1654
|
+
}
|
|
1655
|
+
}
|
|
1656
|
+
}
|
|
1657
|
+
],
|
|
1658
|
+
onStatusChange: (status) => {
|
|
1659
|
+
if (status === "connected") markReady?.(true);
|
|
1660
|
+
if (status === "disconnected") markReady?.(false);
|
|
1661
|
+
},
|
|
1662
|
+
onBackfill: () => {
|
|
1663
|
+
void backfillConversation(id);
|
|
1664
|
+
outbox.flush();
|
|
1665
|
+
}
|
|
1666
|
+
});
|
|
1667
|
+
openChannels.set(id, handle);
|
|
1668
|
+
channelReadiness.set(id, ready);
|
|
1669
|
+
settleChannelReadiness.set(id, (connected) => markReady?.(connected));
|
|
1670
|
+
if (handle.status() === "connected") markReady?.(true);
|
|
1671
|
+
return ready;
|
|
1672
|
+
}
|
|
1440
1673
|
const engine = {
|
|
1441
1674
|
store,
|
|
1442
1675
|
outbox,
|
|
@@ -1462,7 +1695,9 @@ function createMessagingEngine(options) {
|
|
|
1462
1695
|
onChange: ({ row }) => {
|
|
1463
1696
|
if (row === null) return;
|
|
1464
1697
|
const message = projectMessage(row, identity.organizationId);
|
|
1465
|
-
const known = store.snapshot().
|
|
1698
|
+
const known = store.snapshot().conversationSummaries.has(
|
|
1699
|
+
message.conversationId
|
|
1700
|
+
);
|
|
1466
1701
|
if (!known) {
|
|
1467
1702
|
void reloadInbox().catch((error) => reportError(error, "inboxRefresh"));
|
|
1468
1703
|
return;
|
|
@@ -1470,6 +1705,9 @@ function createMessagingEngine(options) {
|
|
|
1470
1705
|
store.ingest(message);
|
|
1471
1706
|
const isMine = message.senderId === identity.userId;
|
|
1472
1707
|
store.applyLastMessage(message, { incrementUnread: !isMine });
|
|
1708
|
+
if (!isMine && store.snapshot().activeConversationId === message.conversationId) {
|
|
1709
|
+
void persistRead(message.conversationId).catch(() => void 0);
|
|
1710
|
+
}
|
|
1473
1711
|
if (!isMine) options.onIncoming?.(message);
|
|
1474
1712
|
}
|
|
1475
1713
|
},
|
|
@@ -1534,18 +1772,35 @@ function createMessagingEngine(options) {
|
|
|
1534
1772
|
},
|
|
1535
1773
|
async openConversation(id) {
|
|
1536
1774
|
store.setActiveConversation(id);
|
|
1775
|
+
const generation = beginInitialRead(id);
|
|
1776
|
+
let ownedChannel = null;
|
|
1537
1777
|
try {
|
|
1778
|
+
const connected = await ensureConversationChannel(id);
|
|
1779
|
+
ownedChannel = openChannels.get(id) ?? null;
|
|
1780
|
+
if (!connected || !openIsLive(id, generation, ownedChannel)) return;
|
|
1538
1781
|
const page = await repository.listMessages(id, { limit: messagePageSize });
|
|
1539
|
-
if (
|
|
1540
|
-
|
|
1541
|
-
|
|
1782
|
+
if (!openIsLive(id, generation, ownedChannel)) return;
|
|
1783
|
+
if (!installInitialPage(id, generation, page)) return;
|
|
1784
|
+
const missingSummary = !store.snapshot().conversationSummaries.has(id);
|
|
1785
|
+
const directSummary = missingSummary ? await repository.getConversationSummary(id) : null;
|
|
1786
|
+
if (!openIsLive(id, generation, ownedChannel)) return;
|
|
1542
1787
|
store.setConversationReadFailure(id, null);
|
|
1543
|
-
|
|
1788
|
+
if (directSummary !== null) store.setConversationSummary(directSummary);
|
|
1789
|
+
await persistRead(
|
|
1790
|
+
id,
|
|
1791
|
+
() => openIsLive(id, generation, ownedChannel)
|
|
1792
|
+
).catch(() => void 0);
|
|
1544
1793
|
} catch (error) {
|
|
1794
|
+
if (!openIsLive(id, generation, ownedChannel)) return;
|
|
1545
1795
|
const normalized = normalizeMessagingError(error, "openConversation");
|
|
1546
1796
|
const unavailable = normalized.code === "not-found" || normalized.code === "forbidden";
|
|
1547
1797
|
store.setConversationReadFailure(id, { error: normalized, unavailable });
|
|
1548
1798
|
if (unavailable) {
|
|
1799
|
+
openChannels.get(id)?.close();
|
|
1800
|
+
openChannels.delete(id);
|
|
1801
|
+
settleChannelReadiness.delete(id);
|
|
1802
|
+
channelReadiness.delete(id);
|
|
1803
|
+
store.clearThread(id);
|
|
1549
1804
|
report({
|
|
1550
1805
|
level: "warn",
|
|
1551
1806
|
message: normalized.message,
|
|
@@ -1554,53 +1809,17 @@ function createMessagingEngine(options) {
|
|
|
1554
1809
|
return;
|
|
1555
1810
|
}
|
|
1556
1811
|
reportError(normalized, "openConversation");
|
|
1812
|
+
} finally {
|
|
1813
|
+
finishInitialRead(id);
|
|
1557
1814
|
}
|
|
1558
|
-
if (openChannels.has(id) || disposed) return;
|
|
1559
|
-
const handle = manager.open({
|
|
1560
|
-
topic: conversationTopic(id),
|
|
1561
|
-
// Presence callbacks are JOIN-TIME in supabase-js. Declare the
|
|
1562
|
-
// read-only room shape on the first holder (the engine) so the
|
|
1563
|
-
// composer/typist/online hooks can attach without forcing a rebuild
|
|
1564
|
-
// after this durable channel has already subscribed.
|
|
1565
|
-
presence: { publish: false, state: {} },
|
|
1566
|
-
postgresChanges: [
|
|
1567
|
-
{
|
|
1568
|
-
event: "*",
|
|
1569
|
-
schema: "communication",
|
|
1570
|
-
table: "dm_messages",
|
|
1571
|
-
filter: `conversation_id=eq.${id}`,
|
|
1572
|
-
rowId: (row) => typeof row["id"] === "string" ? row["id"] : void 0,
|
|
1573
|
-
fingerprint: (row) => typeof row["content"] === "string" ? row["content"] : void 0,
|
|
1574
|
-
updatedAtField: "updated_at",
|
|
1575
|
-
updatedByField: "updated_by",
|
|
1576
|
-
onChange: ({ row }) => {
|
|
1577
|
-
if (row === null) return;
|
|
1578
|
-
store.ingest(projectMessage(row, identity.organizationId));
|
|
1579
|
-
}
|
|
1580
|
-
}
|
|
1581
|
-
],
|
|
1582
|
-
broadcast: [
|
|
1583
|
-
{
|
|
1584
|
-
event: MESSAGING_EVENTS.message,
|
|
1585
|
-
onMessage: ({ data }) => {
|
|
1586
|
-
try {
|
|
1587
|
-
store.ingest(projectBroadcastMessage(data));
|
|
1588
|
-
} catch (error) {
|
|
1589
|
-
reportError(error, "broadcastMessage");
|
|
1590
|
-
}
|
|
1591
|
-
}
|
|
1592
|
-
}
|
|
1593
|
-
],
|
|
1594
|
-
onBackfill: () => {
|
|
1595
|
-
void backfillConversation(id);
|
|
1596
|
-
outbox.flush();
|
|
1597
|
-
}
|
|
1598
|
-
});
|
|
1599
|
-
openChannels.set(id, handle);
|
|
1600
1815
|
},
|
|
1601
1816
|
closeConversation(id) {
|
|
1817
|
+
initialReadGeneration.set(id, (initialReadGeneration.get(id) ?? 0) + 1);
|
|
1602
1818
|
openChannels.get(id)?.close();
|
|
1603
1819
|
openChannels.delete(id);
|
|
1820
|
+
settleChannelReadiness.get(id)?.(false);
|
|
1821
|
+
settleChannelReadiness.delete(id);
|
|
1822
|
+
channelReadiness.delete(id);
|
|
1604
1823
|
if (store.snapshot().activeConversationId === id) {
|
|
1605
1824
|
store.setActiveConversation(null);
|
|
1606
1825
|
}
|
|
@@ -1652,12 +1871,7 @@ function createMessagingEngine(options) {
|
|
|
1652
1871
|
}
|
|
1653
1872
|
},
|
|
1654
1873
|
async markRead(id) {
|
|
1655
|
-
|
|
1656
|
-
try {
|
|
1657
|
-
await repository.markRead(id);
|
|
1658
|
-
} catch (error) {
|
|
1659
|
-
reportError(error, "markRead");
|
|
1660
|
-
}
|
|
1874
|
+
await persistRead(id);
|
|
1661
1875
|
},
|
|
1662
1876
|
async startDirectConversation(otherUserId) {
|
|
1663
1877
|
const id = await repository.getOrCreateDirectConversation(otherUserId);
|
|
@@ -1666,9 +1880,15 @@ function createMessagingEngine(options) {
|
|
|
1666
1880
|
},
|
|
1667
1881
|
dispose() {
|
|
1668
1882
|
disposed = true;
|
|
1883
|
+
openChannels.forEach((_handle, id) => {
|
|
1884
|
+
initialReadGeneration.set(id, (initialReadGeneration.get(id) ?? 0) + 1);
|
|
1885
|
+
});
|
|
1669
1886
|
outbox.dispose();
|
|
1670
1887
|
openChannels.forEach((handle) => handle.close());
|
|
1671
1888
|
openChannels.clear();
|
|
1889
|
+
settleChannelReadiness.forEach((settle) => settle(false));
|
|
1890
|
+
settleChannelReadiness.clear();
|
|
1891
|
+
channelReadiness.clear();
|
|
1672
1892
|
inboxChannel?.close();
|
|
1673
1893
|
inboxChannel = null;
|
|
1674
1894
|
}
|
|
@@ -1925,6 +2145,7 @@ var RPCS = {
|
|
|
1925
2145
|
isParticipant: "is_dm_participant"
|
|
1926
2146
|
};
|
|
1927
2147
|
var MESSAGE_COLUMNS = "id,conversation_id,sender_id,organization_id,content,message_type,status,reply_to_id,client_message_id,action_data,media_url,media_thumbnail_url,media_metadata,created_at,edited_at,deleted_at,deleted_for_everyone,metadata";
|
|
2148
|
+
var PARTICIPANT_COLUMNS = "conversation_id,user_id,role,joined_at,last_read_at,is_muted,is_archived";
|
|
1928
2149
|
var CONVERSATION_COLUMNS = "id,type,group_name,group_image_url,created_by,organization_id,created_at,updated_at,metadata";
|
|
1929
2150
|
function optionalOrg(organizationId) {
|
|
1930
2151
|
return typeof organizationId === "string" && organizationId.length > 0 ? organizationId : null;
|
|
@@ -1994,6 +2215,14 @@ function createMessagingRepository(options) {
|
|
|
1994
2215
|
function actingUser(operation) {
|
|
1995
2216
|
return withSessionRetry(operation, () => actingUserId());
|
|
1996
2217
|
}
|
|
2218
|
+
async function updateLiveParticipant(operation, conversationId, userId, patch) {
|
|
2219
|
+
const destination = await organizationOfExisting(conversationId);
|
|
2220
|
+
const { data, error } = await db().from(TABLES.participants).update({ ...patch, organization_id: destination }).eq("conversation_id", conversationId).eq("user_id", userId).eq("organization_id", destination).is("deleted_at", null).select("id").maybeSingle();
|
|
2221
|
+
if (error !== null) throw normalizeMessagingError(error, operation);
|
|
2222
|
+
if (data === null) {
|
|
2223
|
+
throw writeNotApplied(operation, "participant update returned no live row");
|
|
2224
|
+
}
|
|
2225
|
+
}
|
|
1997
2226
|
async function rpc(fn, args, operation) {
|
|
1998
2227
|
const { data, error } = await rpcDb().rpc(fn, args);
|
|
1999
2228
|
if (error !== null) throw normalizeMessagingError(error, operation);
|
|
@@ -2016,6 +2245,33 @@ function createMessagingRepository(options) {
|
|
|
2016
2245
|
});
|
|
2017
2246
|
return found;
|
|
2018
2247
|
}
|
|
2248
|
+
async function allLiveParticipants(conversationId) {
|
|
2249
|
+
const operation = "getConversationSummary";
|
|
2250
|
+
const pageSize = 1e3;
|
|
2251
|
+
const rows = [];
|
|
2252
|
+
for (let from = 0; ; from += pageSize) {
|
|
2253
|
+
const response = await withSessionRetry(
|
|
2254
|
+
operation,
|
|
2255
|
+
async () => db().from(TABLES.participants).select(PARTICIPANT_COLUMNS, { count: "exact" }).eq("conversation_id", conversationId).is("deleted_at", null).order("user_id", { ascending: true }).range(from, from + pageSize - 1)
|
|
2256
|
+
);
|
|
2257
|
+
if (response.error !== null) {
|
|
2258
|
+
throw normalizeMessagingError(response.error, operation);
|
|
2259
|
+
}
|
|
2260
|
+
const total = response.count;
|
|
2261
|
+
if (typeof total !== "number" || !Number.isInteger(total) || total < 0) {
|
|
2262
|
+
throw invalidResponse(operation, "participant read returned no exact count");
|
|
2263
|
+
}
|
|
2264
|
+
const page = response.data ?? [];
|
|
2265
|
+
rows.push(...page);
|
|
2266
|
+
if (rows.length >= total) return rows;
|
|
2267
|
+
if (page.length === 0) {
|
|
2268
|
+
throw invalidResponse(
|
|
2269
|
+
operation,
|
|
2270
|
+
`participant read stopped at ${rows.length} of ${total}`
|
|
2271
|
+
);
|
|
2272
|
+
}
|
|
2273
|
+
}
|
|
2274
|
+
}
|
|
2019
2275
|
const conversationOrganizations = /* @__PURE__ */ new Map();
|
|
2020
2276
|
async function organizationOfExisting(conversationId) {
|
|
2021
2277
|
const known = conversationOrganizations.get(conversationId);
|
|
@@ -2127,6 +2383,78 @@ function createMessagingRepository(options) {
|
|
|
2127
2383
|
);
|
|
2128
2384
|
return summary.conversation;
|
|
2129
2385
|
},
|
|
2386
|
+
async getConversationSummary(id) {
|
|
2387
|
+
const operation = "getConversationSummary";
|
|
2388
|
+
const me = await actingUser(operation);
|
|
2389
|
+
const [conversationResult, participantRows, latestResult, unreadRaw] = await withSessionRetry(
|
|
2390
|
+
operation,
|
|
2391
|
+
() => Promise.all([
|
|
2392
|
+
db().from(TABLES.conversations).select(CONVERSATION_COLUMNS).eq("id", id).single(),
|
|
2393
|
+
allLiveParticipants(id),
|
|
2394
|
+
db().from(TABLES.messages).select(MESSAGE_COLUMNS).eq("conversation_id", id).is("deleted_at", null).order("created_at", { ascending: false }).order("id", { ascending: false }).limit(1).maybeSingle(),
|
|
2395
|
+
rpc(
|
|
2396
|
+
RPCS.unreadCount,
|
|
2397
|
+
{ p_conversation_id: id, p_user_id: me },
|
|
2398
|
+
operation
|
|
2399
|
+
)
|
|
2400
|
+
])
|
|
2401
|
+
);
|
|
2402
|
+
if (conversationResult.error !== null) {
|
|
2403
|
+
throw normalizeMessagingError(conversationResult.error, operation);
|
|
2404
|
+
}
|
|
2405
|
+
if (latestResult.error !== null) {
|
|
2406
|
+
throw normalizeMessagingError(latestResult.error, operation);
|
|
2407
|
+
}
|
|
2408
|
+
const conversation = conversationResult.data;
|
|
2409
|
+
if (conversation === null) {
|
|
2410
|
+
throw invalidResponse(operation, `conversation ${id} returned no row`);
|
|
2411
|
+
}
|
|
2412
|
+
if (typeof unreadRaw !== "number" || !Number.isFinite(unreadRaw) || unreadRaw < 0) {
|
|
2413
|
+
throw invalidResponse(operation, `${RPCS.unreadCount} returned ${JSON.stringify(unreadRaw)}`);
|
|
2414
|
+
}
|
|
2415
|
+
const participantIds = participantRows.flatMap((row) => {
|
|
2416
|
+
const value = row["user_id"];
|
|
2417
|
+
return typeof value === "string" && value.length > 0 ? [projectUserSummary({ user_id: value }).userId] : [];
|
|
2418
|
+
});
|
|
2419
|
+
const profiles = await getUsers(participantIds);
|
|
2420
|
+
const participants = participantIds.map((userId) => {
|
|
2421
|
+
const profile = profiles.get(userId);
|
|
2422
|
+
return profile === void 0 ? {
|
|
2423
|
+
user_id: userId,
|
|
2424
|
+
display_name: userId
|
|
2425
|
+
} : {
|
|
2426
|
+
user_id: profile.userId,
|
|
2427
|
+
display_name: profile.displayName,
|
|
2428
|
+
email: profile.email,
|
|
2429
|
+
avatar_url: profile.avatarUrl,
|
|
2430
|
+
is_agent: profile.isAgent
|
|
2431
|
+
};
|
|
2432
|
+
});
|
|
2433
|
+
const viewer = participantRows.find((row) => row["user_id"] === me);
|
|
2434
|
+
const latest = latestResult.data;
|
|
2435
|
+
return projectConversationSummary(
|
|
2436
|
+
{
|
|
2437
|
+
conversation_id: conversation["id"],
|
|
2438
|
+
conversation_type: conversation["type"],
|
|
2439
|
+
group_name: conversation["group_name"],
|
|
2440
|
+
group_image_url: conversation["group_image_url"],
|
|
2441
|
+
conversation_created_at: conversation["created_at"],
|
|
2442
|
+
conversation_updated_at: conversation["updated_at"],
|
|
2443
|
+
organization_id: conversation["organization_id"],
|
|
2444
|
+
created_by: conversation["created_by"],
|
|
2445
|
+
metadata: conversation["metadata"],
|
|
2446
|
+
participants,
|
|
2447
|
+
last_message_content: latest?.["content"] ?? null,
|
|
2448
|
+
last_message_sender_id: latest?.["sender_id"] ?? null,
|
|
2449
|
+
last_message_at: latest?.["created_at"] ?? null,
|
|
2450
|
+
unread_count: Math.floor(unreadRaw),
|
|
2451
|
+
is_muted: viewer?.["is_muted"] ?? false,
|
|
2452
|
+
is_archived: viewer?.["is_archived"] ?? false
|
|
2453
|
+
},
|
|
2454
|
+
me,
|
|
2455
|
+
org
|
|
2456
|
+
);
|
|
2457
|
+
},
|
|
2130
2458
|
async listMessages(conversationId, args = {}) {
|
|
2131
2459
|
const limit = args.limit ?? 50;
|
|
2132
2460
|
const operation = "listMessages";
|
|
@@ -2272,8 +2600,22 @@ function createMessagingRepository(options) {
|
|
|
2272
2600
|
async markRead(conversationId, at) {
|
|
2273
2601
|
const operation = "markRead";
|
|
2274
2602
|
const me = await actingUser(operation);
|
|
2275
|
-
|
|
2276
|
-
|
|
2603
|
+
await updateLiveParticipant(operation, conversationId, me, {
|
|
2604
|
+
last_read_at: at,
|
|
2605
|
+
updated_by: me
|
|
2606
|
+
});
|
|
2607
|
+
const unread = await withSessionRetry(
|
|
2608
|
+
operation,
|
|
2609
|
+
() => rpc(
|
|
2610
|
+
RPCS.unreadCount,
|
|
2611
|
+
{ p_conversation_id: conversationId, p_user_id: me },
|
|
2612
|
+
operation
|
|
2613
|
+
)
|
|
2614
|
+
);
|
|
2615
|
+
if (typeof unread !== "number" || !Number.isFinite(unread) || unread < 0) {
|
|
2616
|
+
throw invalidResponse(operation, `${RPCS.unreadCount} returned ${JSON.stringify(unread)}`);
|
|
2617
|
+
}
|
|
2618
|
+
return Math.floor(unread);
|
|
2277
2619
|
},
|
|
2278
2620
|
async setConversationFlags(conversationId, flags) {
|
|
2279
2621
|
const operation = "setConversationFlags";
|
|
@@ -2281,8 +2623,7 @@ function createMessagingRepository(options) {
|
|
|
2281
2623
|
const patch = { updated_by: me };
|
|
2282
2624
|
if (flags.isMuted !== void 0) patch["is_muted"] = flags.isMuted;
|
|
2283
2625
|
if (flags.isArchived !== void 0) patch["is_archived"] = flags.isArchived;
|
|
2284
|
-
|
|
2285
|
-
if (error !== null) throw normalizeMessagingError(error, operation);
|
|
2626
|
+
await updateLiveParticipant(operation, conversationId, me, patch);
|
|
2286
2627
|
},
|
|
2287
2628
|
async addMembers(conversationId, memberIds) {
|
|
2288
2629
|
const operation = "addMembers";
|
|
@@ -2302,14 +2643,18 @@ function createMessagingRepository(options) {
|
|
|
2302
2643
|
async removeMember(conversationId, memberId) {
|
|
2303
2644
|
const operation = "removeMember";
|
|
2304
2645
|
const me = await actingUser(operation);
|
|
2305
|
-
|
|
2306
|
-
|
|
2646
|
+
await updateLiveParticipant(operation, conversationId, memberId, {
|
|
2647
|
+
deleted_at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2648
|
+
updated_by: me
|
|
2649
|
+
});
|
|
2307
2650
|
},
|
|
2308
2651
|
async setMemberRole(conversationId, memberId, role) {
|
|
2309
2652
|
const operation = "setMemberRole";
|
|
2310
2653
|
const me = await actingUser(operation);
|
|
2311
|
-
|
|
2312
|
-
|
|
2654
|
+
await updateLiveParticipant(operation, conversationId, memberId, {
|
|
2655
|
+
role,
|
|
2656
|
+
updated_by: me
|
|
2657
|
+
});
|
|
2313
2658
|
},
|
|
2314
2659
|
getUser(userId) {
|
|
2315
2660
|
const operation = "getUser";
|