@ai-matrx/messaging 0.15.13 → 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 +12 -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 +2 -2
package/dist/react.cjs
CHANGED
|
@@ -121,7 +121,8 @@ __export(react_exports, {
|
|
|
121
121
|
useMessagingSnapshot: () => useMessagingSnapshot,
|
|
122
122
|
useOnlineUserIds: () => useOnlineUserIds,
|
|
123
123
|
useRequiredMessagingHost: () => useRequiredMessagingHost,
|
|
124
|
-
useTypists: () => useTypists
|
|
124
|
+
useTypists: () => useTypists,
|
|
125
|
+
writeNotApplied: () => writeNotApplied
|
|
125
126
|
});
|
|
126
127
|
module.exports = __toCommonJS(react_exports);
|
|
127
128
|
|
|
@@ -292,6 +293,13 @@ function invalidResponse(operation, detail) {
|
|
|
292
293
|
"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."
|
|
293
294
|
);
|
|
294
295
|
}
|
|
296
|
+
function writeNotApplied(operation, detail) {
|
|
297
|
+
return new MessagingError(
|
|
298
|
+
"write-not-applied",
|
|
299
|
+
`${operation}: ${detail}`,
|
|
300
|
+
"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."
|
|
301
|
+
);
|
|
302
|
+
}
|
|
295
303
|
|
|
296
304
|
// src/core/references.ts
|
|
297
305
|
var FENCE = /```matrx\s*\n([\s\S]*?)\n?```/g;
|
|
@@ -1025,6 +1033,7 @@ function insertOrdered(messages, message) {
|
|
|
1025
1033
|
}
|
|
1026
1034
|
function createMessagingStore() {
|
|
1027
1035
|
let conversations = [];
|
|
1036
|
+
let conversationSummaries = /* @__PURE__ */ new Map();
|
|
1028
1037
|
let hasMoreConversations = false;
|
|
1029
1038
|
let hasLoadedConversations = false;
|
|
1030
1039
|
let archiveFilter = DEFAULT_MESSAGING_ARCHIVE_FILTER;
|
|
@@ -1033,12 +1042,15 @@ function createMessagingStore() {
|
|
|
1033
1042
|
let activeConversationId = null;
|
|
1034
1043
|
let conversationReadFailures = /* @__PURE__ */ new Map();
|
|
1035
1044
|
let conversationsLoadError = null;
|
|
1045
|
+
const unreadRevisions = /* @__PURE__ */ new Map();
|
|
1046
|
+
const confirmedReadRevisions = /* @__PURE__ */ new Map();
|
|
1036
1047
|
const listeners = /* @__PURE__ */ new Set();
|
|
1037
1048
|
let cached = null;
|
|
1038
1049
|
function snapshot() {
|
|
1039
1050
|
if (cached !== null) return cached;
|
|
1040
1051
|
cached = {
|
|
1041
1052
|
conversations,
|
|
1053
|
+
conversationSummaries,
|
|
1042
1054
|
hasMoreConversations,
|
|
1043
1055
|
hasLoadedConversations,
|
|
1044
1056
|
threads,
|
|
@@ -1057,11 +1069,33 @@ function createMessagingStore() {
|
|
|
1057
1069
|
listeners.forEach((listener) => listener(next));
|
|
1058
1070
|
}
|
|
1059
1071
|
function normalizeConversations(items) {
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1072
|
+
return [...items].sort((a, b) => a.sortAt < b.sortAt ? 1 : a.sortAt > b.sortAt ? -1 : 0);
|
|
1073
|
+
}
|
|
1074
|
+
function replaceConversations(next) {
|
|
1075
|
+
const summaries = new Map(conversationSummaries);
|
|
1076
|
+
next.forEach((item) => {
|
|
1077
|
+
const id = item.conversation.id;
|
|
1078
|
+
if (summaries.get(id)?.unreadCount !== item.unreadCount) {
|
|
1079
|
+
unreadRevisions.set(id, (unreadRevisions.get(id) ?? 0) + 1);
|
|
1080
|
+
}
|
|
1081
|
+
summaries.set(id, item);
|
|
1082
|
+
});
|
|
1083
|
+
conversationSummaries = summaries;
|
|
1084
|
+
conversations = next;
|
|
1085
|
+
}
|
|
1086
|
+
function replaceOneSummary(id, update) {
|
|
1087
|
+
const held = conversationSummaries.get(id);
|
|
1088
|
+
if (held === void 0) return;
|
|
1089
|
+
const replacement = update(held);
|
|
1090
|
+
if (held.unreadCount !== replacement.unreadCount) {
|
|
1091
|
+
unreadRevisions.set(id, (unreadRevisions.get(id) ?? 0) + 1);
|
|
1092
|
+
}
|
|
1093
|
+
const summaries = new Map(conversationSummaries);
|
|
1094
|
+
summaries.set(id, replacement);
|
|
1095
|
+
conversationSummaries = summaries;
|
|
1096
|
+
conversations = conversations.map(
|
|
1097
|
+
(item) => item.conversation.id === id ? replacement : item
|
|
1063
1098
|
);
|
|
1064
|
-
return [...zeroed].sort((a, b) => a.sortAt < b.sortAt ? 1 : a.sortAt > b.sortAt ? -1 : 0);
|
|
1065
1099
|
}
|
|
1066
1100
|
function threadFor(id) {
|
|
1067
1101
|
return threads.get(id) ?? {
|
|
@@ -1073,7 +1107,9 @@ function createMessagingStore() {
|
|
|
1073
1107
|
}
|
|
1074
1108
|
function writeThread(thread) {
|
|
1075
1109
|
const next = new Map(threads);
|
|
1076
|
-
const latest = thread.messages.
|
|
1110
|
+
const latest = [...thread.messages].reverse().find(
|
|
1111
|
+
(message) => message.deliveryState !== "sending" && message.deliveryState !== "failed" && !message.id.startsWith("optimistic:")
|
|
1112
|
+
);
|
|
1077
1113
|
next.set(thread.conversationId, {
|
|
1078
1114
|
...thread,
|
|
1079
1115
|
latestAt: latest?.createdAt ?? thread.latestAt
|
|
@@ -1089,7 +1125,7 @@ function createMessagingStore() {
|
|
|
1089
1125
|
};
|
|
1090
1126
|
},
|
|
1091
1127
|
setConversations(items, hasMore) {
|
|
1092
|
-
|
|
1128
|
+
replaceConversations(normalizeConversations(items));
|
|
1093
1129
|
hasMoreConversations = hasMore;
|
|
1094
1130
|
hasLoadedConversations = true;
|
|
1095
1131
|
conversationsLoadError = null;
|
|
@@ -1103,7 +1139,7 @@ function createMessagingStore() {
|
|
|
1103
1139
|
setArchiveFilter(next) {
|
|
1104
1140
|
if (next === archiveFilter) return;
|
|
1105
1141
|
archiveFilter = next;
|
|
1106
|
-
|
|
1142
|
+
replaceConversations([]);
|
|
1107
1143
|
hasMoreConversations = false;
|
|
1108
1144
|
hasLoadedConversations = false;
|
|
1109
1145
|
emit();
|
|
@@ -1115,18 +1151,34 @@ function createMessagingStore() {
|
|
|
1115
1151
|
appendConversations(items, hasMore) {
|
|
1116
1152
|
const byId = new Map(conversations.map((item) => [item.conversation.id, item]));
|
|
1117
1153
|
items.forEach((item) => byId.set(item.conversation.id, item));
|
|
1118
|
-
|
|
1154
|
+
replaceConversations(normalizeConversations([...byId.values()]));
|
|
1119
1155
|
hasMoreConversations = hasMore;
|
|
1120
1156
|
emit();
|
|
1121
1157
|
},
|
|
1122
1158
|
upsertConversation(item) {
|
|
1123
1159
|
const byId = new Map(conversations.map((entry) => [entry.conversation.id, entry]));
|
|
1124
1160
|
byId.set(item.conversation.id, item);
|
|
1125
|
-
|
|
1161
|
+
replaceConversations(normalizeConversations([...byId.values()]));
|
|
1162
|
+
emit();
|
|
1163
|
+
},
|
|
1164
|
+
setConversationSummary(item) {
|
|
1165
|
+
const id = item.conversation.id;
|
|
1166
|
+
const held = conversationSummaries.get(id);
|
|
1167
|
+
if (held?.unreadCount !== item.unreadCount) {
|
|
1168
|
+
unreadRevisions.set(id, (unreadRevisions.get(id) ?? 0) + 1);
|
|
1169
|
+
}
|
|
1170
|
+
const summaries = new Map(conversationSummaries);
|
|
1171
|
+
summaries.set(id, item);
|
|
1172
|
+
conversationSummaries = summaries;
|
|
1126
1173
|
emit();
|
|
1127
1174
|
},
|
|
1128
1175
|
removeConversation(id) {
|
|
1129
|
-
conversations
|
|
1176
|
+
replaceConversations(conversations.filter((item) => item.conversation.id !== id));
|
|
1177
|
+
const summaries = new Map(conversationSummaries);
|
|
1178
|
+
summaries.delete(id);
|
|
1179
|
+
conversationSummaries = summaries;
|
|
1180
|
+
unreadRevisions.delete(id);
|
|
1181
|
+
confirmedReadRevisions.delete(id);
|
|
1130
1182
|
const next = new Map(threads);
|
|
1131
1183
|
next.delete(id);
|
|
1132
1184
|
threads = next;
|
|
@@ -1134,7 +1186,6 @@ function createMessagingStore() {
|
|
|
1134
1186
|
},
|
|
1135
1187
|
setActiveConversation(id) {
|
|
1136
1188
|
activeConversationId = id;
|
|
1137
|
-
conversations = normalizeConversations(conversations);
|
|
1138
1189
|
emit();
|
|
1139
1190
|
},
|
|
1140
1191
|
setConversationReadFailure(id, failure) {
|
|
@@ -1154,6 +1205,13 @@ function createMessagingStore() {
|
|
|
1154
1205
|
});
|
|
1155
1206
|
emit();
|
|
1156
1207
|
},
|
|
1208
|
+
clearThread(id) {
|
|
1209
|
+
if (!threads.has(id)) return;
|
|
1210
|
+
const next = new Map(threads);
|
|
1211
|
+
next.delete(id);
|
|
1212
|
+
threads = next;
|
|
1213
|
+
emit();
|
|
1214
|
+
},
|
|
1157
1215
|
prependOlder(id, messages, hasMoreOlder) {
|
|
1158
1216
|
const thread = threadFor(id);
|
|
1159
1217
|
const known = new Set(thread.messages.map((message) => message.id));
|
|
@@ -1206,40 +1264,48 @@ function createMessagingStore() {
|
|
|
1206
1264
|
emit();
|
|
1207
1265
|
},
|
|
1208
1266
|
applyLastMessage(message, args = {}) {
|
|
1209
|
-
const existing =
|
|
1210
|
-
(item) => item.conversation.id === message.conversationId
|
|
1211
|
-
);
|
|
1267
|
+
const existing = conversationSummaries.get(message.conversationId);
|
|
1212
1268
|
if (existing === void 0) return;
|
|
1213
1269
|
if (message.deliveryState === "sending" || message.deletedAt !== null) return;
|
|
1214
1270
|
if (existing.lastMessageAt !== null && message.createdAt < existing.lastMessageAt) return;
|
|
1215
|
-
const
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
unreadCount: shouldCount ? item.unreadCount + 1 : item.unreadCount
|
|
1226
|
-
} : item
|
|
1227
|
-
)
|
|
1228
|
-
);
|
|
1271
|
+
const shouldCount = args.incrementUnread === true;
|
|
1272
|
+
replaceOneSummary(message.conversationId, (item) => ({
|
|
1273
|
+
...item,
|
|
1274
|
+
lastMessageContent: message.content,
|
|
1275
|
+
lastMessageSenderId: message.senderId,
|
|
1276
|
+
lastMessageAt: message.createdAt,
|
|
1277
|
+
sortAt: message.createdAt,
|
|
1278
|
+
unreadCount: shouldCount ? item.unreadCount + 1 : item.unreadCount
|
|
1279
|
+
}));
|
|
1280
|
+
conversations = normalizeConversations(conversations);
|
|
1229
1281
|
emit();
|
|
1230
1282
|
},
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1283
|
+
captureConversationRead(id, at) {
|
|
1284
|
+
const item = conversationSummaries.get(id);
|
|
1285
|
+
return {
|
|
1286
|
+
at,
|
|
1287
|
+
unreadCount: item?.unreadCount ?? 0,
|
|
1288
|
+
revision: unreadRevisions.get(id) ?? 0
|
|
1289
|
+
};
|
|
1290
|
+
},
|
|
1291
|
+
markConversationRead(id, cutoff, canonicalUnreadCount = 0) {
|
|
1292
|
+
if (cutoff.revision <= (confirmedReadRevisions.get(id) ?? -1)) return;
|
|
1293
|
+
confirmedReadRevisions.set(id, cutoff.revision);
|
|
1294
|
+
replaceOneSummary(id, (item) => ({
|
|
1295
|
+
...item,
|
|
1296
|
+
// The repository re-counts after persisting the exact server cutoff.
|
|
1297
|
+
// If realtime advanced local state while that response was in flight,
|
|
1298
|
+
// retain at least the arrivals beyond the captured baseline.
|
|
1299
|
+
unreadCount: Math.max(
|
|
1300
|
+
0,
|
|
1301
|
+
canonicalUnreadCount,
|
|
1302
|
+
item.unreadCount - cutoff.unreadCount
|
|
1303
|
+
)
|
|
1304
|
+
}));
|
|
1235
1305
|
emit();
|
|
1236
1306
|
},
|
|
1237
1307
|
setUnreadCount(id, count) {
|
|
1238
|
-
|
|
1239
|
-
conversations.map(
|
|
1240
|
-
(item) => item.conversation.id === id ? { ...item, unreadCount: Math.max(0, count) } : item
|
|
1241
|
-
)
|
|
1242
|
-
);
|
|
1308
|
+
replaceOneSummary(id, (item) => ({ ...item, unreadCount: Math.max(0, count) }));
|
|
1243
1309
|
emit();
|
|
1244
1310
|
}
|
|
1245
1311
|
};
|
|
@@ -1276,21 +1342,57 @@ function createMessagingEngine(options) {
|
|
|
1276
1342
|
const conversationPageSize = options.conversationPageSize ?? 30;
|
|
1277
1343
|
const messagePageSize = options.messagePageSize ?? 50;
|
|
1278
1344
|
const openChannels = /* @__PURE__ */ new Map();
|
|
1345
|
+
const channelReadiness = /* @__PURE__ */ new Map();
|
|
1346
|
+
const settleChannelReadiness = /* @__PURE__ */ new Map();
|
|
1347
|
+
const readReceiptTails = /* @__PURE__ */ new Map();
|
|
1348
|
+
const persistedReadAt = /* @__PURE__ */ new Map();
|
|
1349
|
+
const initialReadDepth = /* @__PURE__ */ new Map();
|
|
1350
|
+
const initialReadGeneration = /* @__PURE__ */ new Map();
|
|
1351
|
+
const initialReadChanges = /* @__PURE__ */ new Map();
|
|
1279
1352
|
let inboxChannel = null;
|
|
1280
1353
|
let conversationCursor = null;
|
|
1281
1354
|
let disposed = false;
|
|
1282
1355
|
function report(event) {
|
|
1283
1356
|
options.onDiagnostic?.(event);
|
|
1284
1357
|
}
|
|
1285
|
-
function reportError(error, operation) {
|
|
1358
|
+
function reportError(error, operation, context = {}) {
|
|
1286
1359
|
const normalized = normalizeMessagingError(error, operation);
|
|
1287
1360
|
report({
|
|
1288
1361
|
// A missing session is a NORMAL lifecycle moment, not a red error. This
|
|
1289
1362
|
// one line is the cure for "909 captured errors in 0.6s".
|
|
1290
1363
|
level: normalized.code === "session-unavailable" ? "warn" : "error",
|
|
1291
1364
|
message: normalized.message,
|
|
1292
|
-
remedy: normalized.remedy
|
|
1365
|
+
remedy: normalized.remedy,
|
|
1366
|
+
code: normalized.code,
|
|
1367
|
+
operation,
|
|
1368
|
+
isRetryable: normalized.isRetryable,
|
|
1369
|
+
error: normalized,
|
|
1370
|
+
...context
|
|
1293
1371
|
});
|
|
1372
|
+
return normalized;
|
|
1373
|
+
}
|
|
1374
|
+
function persistRead(id, isLive = () => true) {
|
|
1375
|
+
const run = async () => {
|
|
1376
|
+
if (!isLive()) return;
|
|
1377
|
+
const at = store.snapshot().threads.get(id)?.latestAt ?? null;
|
|
1378
|
+
if (at === null || (persistedReadAt.get(id) ?? "") >= at) return;
|
|
1379
|
+
const cutoff = store.captureConversationRead(id, at);
|
|
1380
|
+
try {
|
|
1381
|
+
const canonicalUnreadCount = await repository.markRead(id, at);
|
|
1382
|
+
if (!isLive()) return;
|
|
1383
|
+
persistedReadAt.set(id, at);
|
|
1384
|
+
store.markConversationRead(id, cutoff, canonicalUnreadCount);
|
|
1385
|
+
} catch (error) {
|
|
1386
|
+
throw reportError(error, "markRead", { conversationId: id });
|
|
1387
|
+
}
|
|
1388
|
+
};
|
|
1389
|
+
const previous = readReceiptTails.get(id);
|
|
1390
|
+
const current = previous === void 0 ? run() : previous.catch(() => void 0).then(run);
|
|
1391
|
+
readReceiptTails.set(id, current);
|
|
1392
|
+
void current.finally(() => {
|
|
1393
|
+
if (readReceiptTails.get(id) === current) readReceiptTails.delete(id);
|
|
1394
|
+
}).catch(() => void 0);
|
|
1395
|
+
return current;
|
|
1294
1396
|
}
|
|
1295
1397
|
const outbox = createOutbox({
|
|
1296
1398
|
...options.outboxStorage !== void 0 ? { storage: options.outboxStorage } : {},
|
|
@@ -1375,6 +1477,137 @@ function createMessagingEngine(options) {
|
|
|
1375
1477
|
reportError(error, "backfillConversation");
|
|
1376
1478
|
}
|
|
1377
1479
|
}
|
|
1480
|
+
function applyLiveMessageChange(id, change) {
|
|
1481
|
+
if ((initialReadDepth.get(id) ?? 0) > 0) {
|
|
1482
|
+
const changes = initialReadChanges.get(id) ?? [];
|
|
1483
|
+
changes.push(change);
|
|
1484
|
+
initialReadChanges.set(id, changes);
|
|
1485
|
+
}
|
|
1486
|
+
if (change.kind === "delete") {
|
|
1487
|
+
store.removeMessage(id, change.messageId);
|
|
1488
|
+
} else {
|
|
1489
|
+
store.ingest(change.message);
|
|
1490
|
+
}
|
|
1491
|
+
}
|
|
1492
|
+
function beginInitialRead(id) {
|
|
1493
|
+
const generation = (initialReadGeneration.get(id) ?? 0) + 1;
|
|
1494
|
+
initialReadGeneration.set(id, generation);
|
|
1495
|
+
initialReadDepth.set(id, (initialReadDepth.get(id) ?? 0) + 1);
|
|
1496
|
+
initialReadChanges.set(id, initialReadChanges.get(id) ?? []);
|
|
1497
|
+
return generation;
|
|
1498
|
+
}
|
|
1499
|
+
function finishInitialRead(id) {
|
|
1500
|
+
const depth = (initialReadDepth.get(id) ?? 1) - 1;
|
|
1501
|
+
if (depth > 0) {
|
|
1502
|
+
initialReadDepth.set(id, depth);
|
|
1503
|
+
return;
|
|
1504
|
+
}
|
|
1505
|
+
initialReadDepth.delete(id);
|
|
1506
|
+
initialReadChanges.delete(id);
|
|
1507
|
+
}
|
|
1508
|
+
function openIsLive(id, generation, channel) {
|
|
1509
|
+
return !disposed && initialReadGeneration.get(id) === generation && channel !== null && openChannels.get(id) === channel;
|
|
1510
|
+
}
|
|
1511
|
+
function installInitialPage(id, generation, page) {
|
|
1512
|
+
if (initialReadGeneration.get(id) !== generation) return false;
|
|
1513
|
+
const optimistic = (store.snapshot().threads.get(id)?.messages ?? []).filter(
|
|
1514
|
+
(item) => item.deliveryState === "sending" || item.deliveryState === "failed"
|
|
1515
|
+
);
|
|
1516
|
+
const confirmedClientKeys = new Set(
|
|
1517
|
+
page.items.flatMap(
|
|
1518
|
+
(item) => item.clientMessageId === null ? [] : [item.clientMessageId]
|
|
1519
|
+
)
|
|
1520
|
+
);
|
|
1521
|
+
store.setThread(id, page.items, { hasMoreOlder: page.hasMore });
|
|
1522
|
+
for (const change of initialReadChanges.get(id) ?? []) {
|
|
1523
|
+
if (change.kind === "delete") {
|
|
1524
|
+
store.removeMessage(id, change.messageId);
|
|
1525
|
+
} else {
|
|
1526
|
+
store.ingest(change.message);
|
|
1527
|
+
}
|
|
1528
|
+
}
|
|
1529
|
+
store.ingestMany(
|
|
1530
|
+
optimistic.filter(
|
|
1531
|
+
(item) => item.clientMessageId === null || !confirmedClientKeys.has(item.clientMessageId)
|
|
1532
|
+
)
|
|
1533
|
+
);
|
|
1534
|
+
return true;
|
|
1535
|
+
}
|
|
1536
|
+
function ensureConversationChannel(id) {
|
|
1537
|
+
const existing = openChannels.get(id);
|
|
1538
|
+
if (existing !== void 0) {
|
|
1539
|
+
return channelReadiness.get(id) ?? Promise.resolve(existing.status() === "connected");
|
|
1540
|
+
}
|
|
1541
|
+
let markReady;
|
|
1542
|
+
const ready = new Promise((resolve) => {
|
|
1543
|
+
markReady = resolve;
|
|
1544
|
+
});
|
|
1545
|
+
const handle = manager.open({
|
|
1546
|
+
topic: conversationTopic(id),
|
|
1547
|
+
// Presence callbacks are JOIN-TIME in supabase-js. Declare the read-only
|
|
1548
|
+
// room shape on the first holder so later holders do not create a gap.
|
|
1549
|
+
presence: { publish: false, state: {} },
|
|
1550
|
+
postgresChanges: [
|
|
1551
|
+
{
|
|
1552
|
+
event: "*",
|
|
1553
|
+
schema: "communication",
|
|
1554
|
+
table: "dm_messages",
|
|
1555
|
+
filter: `conversation_id=eq.${id}`,
|
|
1556
|
+
rowId: (row) => typeof row["id"] === "string" ? row["id"] : void 0,
|
|
1557
|
+
fingerprint: (row) => typeof row["content"] === "string" ? row["content"] : void 0,
|
|
1558
|
+
updatedAtField: "updated_at",
|
|
1559
|
+
updatedByField: "updated_by",
|
|
1560
|
+
onChange: ({ payload, row }) => {
|
|
1561
|
+
if (row === null) return;
|
|
1562
|
+
if (payload?.eventType === "DELETE" || typeof row["deleted_at"] === "string" && row["deleted_at"].length > 0) {
|
|
1563
|
+
const rawId = row["id"];
|
|
1564
|
+
if (typeof rawId !== "string" || rawId.length === 0) {
|
|
1565
|
+
reportError(
|
|
1566
|
+
new Error("A message deletion payload carried no message id."),
|
|
1567
|
+
"conversationMessageDelete",
|
|
1568
|
+
{ conversationId: id }
|
|
1569
|
+
);
|
|
1570
|
+
return;
|
|
1571
|
+
}
|
|
1572
|
+
applyLiveMessageChange(id, {
|
|
1573
|
+
kind: "delete",
|
|
1574
|
+
messageId: asMessageId(rawId)
|
|
1575
|
+
});
|
|
1576
|
+
return;
|
|
1577
|
+
}
|
|
1578
|
+
const message = projectMessage(row, identity.organizationId);
|
|
1579
|
+
applyLiveMessageChange(id, { kind: "upsert", message });
|
|
1580
|
+
}
|
|
1581
|
+
}
|
|
1582
|
+
],
|
|
1583
|
+
broadcast: [
|
|
1584
|
+
{
|
|
1585
|
+
event: MESSAGING_EVENTS.message,
|
|
1586
|
+
onMessage: ({ data }) => {
|
|
1587
|
+
try {
|
|
1588
|
+
const message = projectBroadcastMessage(data);
|
|
1589
|
+
applyLiveMessageChange(id, { kind: "upsert", message });
|
|
1590
|
+
} catch (error) {
|
|
1591
|
+
reportError(error, "broadcastMessage");
|
|
1592
|
+
}
|
|
1593
|
+
}
|
|
1594
|
+
}
|
|
1595
|
+
],
|
|
1596
|
+
onStatusChange: (status) => {
|
|
1597
|
+
if (status === "connected") markReady?.(true);
|
|
1598
|
+
if (status === "disconnected") markReady?.(false);
|
|
1599
|
+
},
|
|
1600
|
+
onBackfill: () => {
|
|
1601
|
+
void backfillConversation(id);
|
|
1602
|
+
outbox.flush();
|
|
1603
|
+
}
|
|
1604
|
+
});
|
|
1605
|
+
openChannels.set(id, handle);
|
|
1606
|
+
channelReadiness.set(id, ready);
|
|
1607
|
+
settleChannelReadiness.set(id, (connected) => markReady?.(connected));
|
|
1608
|
+
if (handle.status() === "connected") markReady?.(true);
|
|
1609
|
+
return ready;
|
|
1610
|
+
}
|
|
1378
1611
|
const engine = {
|
|
1379
1612
|
store,
|
|
1380
1613
|
outbox,
|
|
@@ -1400,7 +1633,9 @@ function createMessagingEngine(options) {
|
|
|
1400
1633
|
onChange: ({ row }) => {
|
|
1401
1634
|
if (row === null) return;
|
|
1402
1635
|
const message = projectMessage(row, identity.organizationId);
|
|
1403
|
-
const known = store.snapshot().
|
|
1636
|
+
const known = store.snapshot().conversationSummaries.has(
|
|
1637
|
+
message.conversationId
|
|
1638
|
+
);
|
|
1404
1639
|
if (!known) {
|
|
1405
1640
|
void reloadInbox().catch((error) => reportError(error, "inboxRefresh"));
|
|
1406
1641
|
return;
|
|
@@ -1408,6 +1643,9 @@ function createMessagingEngine(options) {
|
|
|
1408
1643
|
store.ingest(message);
|
|
1409
1644
|
const isMine = message.senderId === identity.userId;
|
|
1410
1645
|
store.applyLastMessage(message, { incrementUnread: !isMine });
|
|
1646
|
+
if (!isMine && store.snapshot().activeConversationId === message.conversationId) {
|
|
1647
|
+
void persistRead(message.conversationId).catch(() => void 0);
|
|
1648
|
+
}
|
|
1411
1649
|
if (!isMine) options.onIncoming?.(message);
|
|
1412
1650
|
}
|
|
1413
1651
|
},
|
|
@@ -1472,18 +1710,35 @@ function createMessagingEngine(options) {
|
|
|
1472
1710
|
},
|
|
1473
1711
|
async openConversation(id) {
|
|
1474
1712
|
store.setActiveConversation(id);
|
|
1713
|
+
const generation = beginInitialRead(id);
|
|
1714
|
+
let ownedChannel = null;
|
|
1475
1715
|
try {
|
|
1716
|
+
const connected = await ensureConversationChannel(id);
|
|
1717
|
+
ownedChannel = openChannels.get(id) ?? null;
|
|
1718
|
+
if (!connected || !openIsLive(id, generation, ownedChannel)) return;
|
|
1476
1719
|
const page = await repository.listMessages(id, { limit: messagePageSize });
|
|
1477
|
-
if (
|
|
1478
|
-
|
|
1479
|
-
|
|
1720
|
+
if (!openIsLive(id, generation, ownedChannel)) return;
|
|
1721
|
+
if (!installInitialPage(id, generation, page)) return;
|
|
1722
|
+
const missingSummary = !store.snapshot().conversationSummaries.has(id);
|
|
1723
|
+
const directSummary = missingSummary ? await repository.getConversationSummary(id) : null;
|
|
1724
|
+
if (!openIsLive(id, generation, ownedChannel)) return;
|
|
1480
1725
|
store.setConversationReadFailure(id, null);
|
|
1481
|
-
|
|
1726
|
+
if (directSummary !== null) store.setConversationSummary(directSummary);
|
|
1727
|
+
await persistRead(
|
|
1728
|
+
id,
|
|
1729
|
+
() => openIsLive(id, generation, ownedChannel)
|
|
1730
|
+
).catch(() => void 0);
|
|
1482
1731
|
} catch (error) {
|
|
1732
|
+
if (!openIsLive(id, generation, ownedChannel)) return;
|
|
1483
1733
|
const normalized = normalizeMessagingError(error, "openConversation");
|
|
1484
1734
|
const unavailable = normalized.code === "not-found" || normalized.code === "forbidden";
|
|
1485
1735
|
store.setConversationReadFailure(id, { error: normalized, unavailable });
|
|
1486
1736
|
if (unavailable) {
|
|
1737
|
+
openChannels.get(id)?.close();
|
|
1738
|
+
openChannels.delete(id);
|
|
1739
|
+
settleChannelReadiness.delete(id);
|
|
1740
|
+
channelReadiness.delete(id);
|
|
1741
|
+
store.clearThread(id);
|
|
1487
1742
|
report({
|
|
1488
1743
|
level: "warn",
|
|
1489
1744
|
message: normalized.message,
|
|
@@ -1492,53 +1747,17 @@ function createMessagingEngine(options) {
|
|
|
1492
1747
|
return;
|
|
1493
1748
|
}
|
|
1494
1749
|
reportError(normalized, "openConversation");
|
|
1750
|
+
} finally {
|
|
1751
|
+
finishInitialRead(id);
|
|
1495
1752
|
}
|
|
1496
|
-
if (openChannels.has(id) || disposed) return;
|
|
1497
|
-
const handle = manager.open({
|
|
1498
|
-
topic: conversationTopic(id),
|
|
1499
|
-
// Presence callbacks are JOIN-TIME in supabase-js. Declare the
|
|
1500
|
-
// read-only room shape on the first holder (the engine) so the
|
|
1501
|
-
// composer/typist/online hooks can attach without forcing a rebuild
|
|
1502
|
-
// after this durable channel has already subscribed.
|
|
1503
|
-
presence: { publish: false, state: {} },
|
|
1504
|
-
postgresChanges: [
|
|
1505
|
-
{
|
|
1506
|
-
event: "*",
|
|
1507
|
-
schema: "communication",
|
|
1508
|
-
table: "dm_messages",
|
|
1509
|
-
filter: `conversation_id=eq.${id}`,
|
|
1510
|
-
rowId: (row) => typeof row["id"] === "string" ? row["id"] : void 0,
|
|
1511
|
-
fingerprint: (row) => typeof row["content"] === "string" ? row["content"] : void 0,
|
|
1512
|
-
updatedAtField: "updated_at",
|
|
1513
|
-
updatedByField: "updated_by",
|
|
1514
|
-
onChange: ({ row }) => {
|
|
1515
|
-
if (row === null) return;
|
|
1516
|
-
store.ingest(projectMessage(row, identity.organizationId));
|
|
1517
|
-
}
|
|
1518
|
-
}
|
|
1519
|
-
],
|
|
1520
|
-
broadcast: [
|
|
1521
|
-
{
|
|
1522
|
-
event: MESSAGING_EVENTS.message,
|
|
1523
|
-
onMessage: ({ data }) => {
|
|
1524
|
-
try {
|
|
1525
|
-
store.ingest(projectBroadcastMessage(data));
|
|
1526
|
-
} catch (error) {
|
|
1527
|
-
reportError(error, "broadcastMessage");
|
|
1528
|
-
}
|
|
1529
|
-
}
|
|
1530
|
-
}
|
|
1531
|
-
],
|
|
1532
|
-
onBackfill: () => {
|
|
1533
|
-
void backfillConversation(id);
|
|
1534
|
-
outbox.flush();
|
|
1535
|
-
}
|
|
1536
|
-
});
|
|
1537
|
-
openChannels.set(id, handle);
|
|
1538
1753
|
},
|
|
1539
1754
|
closeConversation(id) {
|
|
1755
|
+
initialReadGeneration.set(id, (initialReadGeneration.get(id) ?? 0) + 1);
|
|
1540
1756
|
openChannels.get(id)?.close();
|
|
1541
1757
|
openChannels.delete(id);
|
|
1758
|
+
settleChannelReadiness.get(id)?.(false);
|
|
1759
|
+
settleChannelReadiness.delete(id);
|
|
1760
|
+
channelReadiness.delete(id);
|
|
1542
1761
|
if (store.snapshot().activeConversationId === id) {
|
|
1543
1762
|
store.setActiveConversation(null);
|
|
1544
1763
|
}
|
|
@@ -1590,12 +1809,7 @@ function createMessagingEngine(options) {
|
|
|
1590
1809
|
}
|
|
1591
1810
|
},
|
|
1592
1811
|
async markRead(id) {
|
|
1593
|
-
|
|
1594
|
-
try {
|
|
1595
|
-
await repository.markRead(id);
|
|
1596
|
-
} catch (error) {
|
|
1597
|
-
reportError(error, "markRead");
|
|
1598
|
-
}
|
|
1812
|
+
await persistRead(id);
|
|
1599
1813
|
},
|
|
1600
1814
|
async startDirectConversation(otherUserId) {
|
|
1601
1815
|
const id = await repository.getOrCreateDirectConversation(otherUserId);
|
|
@@ -1604,9 +1818,15 @@ function createMessagingEngine(options) {
|
|
|
1604
1818
|
},
|
|
1605
1819
|
dispose() {
|
|
1606
1820
|
disposed = true;
|
|
1821
|
+
openChannels.forEach((_handle, id) => {
|
|
1822
|
+
initialReadGeneration.set(id, (initialReadGeneration.get(id) ?? 0) + 1);
|
|
1823
|
+
});
|
|
1607
1824
|
outbox.dispose();
|
|
1608
1825
|
openChannels.forEach((handle) => handle.close());
|
|
1609
1826
|
openChannels.clear();
|
|
1827
|
+
settleChannelReadiness.forEach((settle) => settle(false));
|
|
1828
|
+
settleChannelReadiness.clear();
|
|
1829
|
+
channelReadiness.clear();
|
|
1610
1830
|
inboxChannel?.close();
|
|
1611
1831
|
inboxChannel = null;
|
|
1612
1832
|
}
|
|
@@ -1820,6 +2040,7 @@ var RPCS = {
|
|
|
1820
2040
|
isParticipant: "is_dm_participant"
|
|
1821
2041
|
};
|
|
1822
2042
|
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";
|
|
2043
|
+
var PARTICIPANT_COLUMNS = "conversation_id,user_id,role,joined_at,last_read_at,is_muted,is_archived";
|
|
1823
2044
|
var CONVERSATION_COLUMNS = "id,type,group_name,group_image_url,created_by,organization_id,created_at,updated_at,metadata";
|
|
1824
2045
|
function optionalOrg(organizationId) {
|
|
1825
2046
|
return typeof organizationId === "string" && organizationId.length > 0 ? organizationId : null;
|
|
@@ -1889,6 +2110,14 @@ function createMessagingRepository(options) {
|
|
|
1889
2110
|
function actingUser(operation) {
|
|
1890
2111
|
return withSessionRetry(operation, () => actingUserId());
|
|
1891
2112
|
}
|
|
2113
|
+
async function updateLiveParticipant(operation, conversationId, userId, patch) {
|
|
2114
|
+
const destination = await organizationOfExisting(conversationId);
|
|
2115
|
+
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();
|
|
2116
|
+
if (error !== null) throw normalizeMessagingError(error, operation);
|
|
2117
|
+
if (data === null) {
|
|
2118
|
+
throw writeNotApplied(operation, "participant update returned no live row");
|
|
2119
|
+
}
|
|
2120
|
+
}
|
|
1892
2121
|
async function rpc(fn, args, operation) {
|
|
1893
2122
|
const { data, error } = await rpcDb().rpc(fn, args);
|
|
1894
2123
|
if (error !== null) throw normalizeMessagingError(error, operation);
|
|
@@ -1911,6 +2140,33 @@ function createMessagingRepository(options) {
|
|
|
1911
2140
|
});
|
|
1912
2141
|
return found;
|
|
1913
2142
|
}
|
|
2143
|
+
async function allLiveParticipants(conversationId) {
|
|
2144
|
+
const operation = "getConversationSummary";
|
|
2145
|
+
const pageSize = 1e3;
|
|
2146
|
+
const rows = [];
|
|
2147
|
+
for (let from = 0; ; from += pageSize) {
|
|
2148
|
+
const response = await withSessionRetry(
|
|
2149
|
+
operation,
|
|
2150
|
+
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)
|
|
2151
|
+
);
|
|
2152
|
+
if (response.error !== null) {
|
|
2153
|
+
throw normalizeMessagingError(response.error, operation);
|
|
2154
|
+
}
|
|
2155
|
+
const total = response.count;
|
|
2156
|
+
if (typeof total !== "number" || !Number.isInteger(total) || total < 0) {
|
|
2157
|
+
throw invalidResponse(operation, "participant read returned no exact count");
|
|
2158
|
+
}
|
|
2159
|
+
const page = response.data ?? [];
|
|
2160
|
+
rows.push(...page);
|
|
2161
|
+
if (rows.length >= total) return rows;
|
|
2162
|
+
if (page.length === 0) {
|
|
2163
|
+
throw invalidResponse(
|
|
2164
|
+
operation,
|
|
2165
|
+
`participant read stopped at ${rows.length} of ${total}`
|
|
2166
|
+
);
|
|
2167
|
+
}
|
|
2168
|
+
}
|
|
2169
|
+
}
|
|
1914
2170
|
const conversationOrganizations = /* @__PURE__ */ new Map();
|
|
1915
2171
|
async function organizationOfExisting(conversationId) {
|
|
1916
2172
|
const known = conversationOrganizations.get(conversationId);
|
|
@@ -2022,6 +2278,78 @@ function createMessagingRepository(options) {
|
|
|
2022
2278
|
);
|
|
2023
2279
|
return summary.conversation;
|
|
2024
2280
|
},
|
|
2281
|
+
async getConversationSummary(id) {
|
|
2282
|
+
const operation = "getConversationSummary";
|
|
2283
|
+
const me = await actingUser(operation);
|
|
2284
|
+
const [conversationResult, participantRows, latestResult, unreadRaw] = await withSessionRetry(
|
|
2285
|
+
operation,
|
|
2286
|
+
() => Promise.all([
|
|
2287
|
+
db().from(TABLES.conversations).select(CONVERSATION_COLUMNS).eq("id", id).single(),
|
|
2288
|
+
allLiveParticipants(id),
|
|
2289
|
+
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(),
|
|
2290
|
+
rpc(
|
|
2291
|
+
RPCS.unreadCount,
|
|
2292
|
+
{ p_conversation_id: id, p_user_id: me },
|
|
2293
|
+
operation
|
|
2294
|
+
)
|
|
2295
|
+
])
|
|
2296
|
+
);
|
|
2297
|
+
if (conversationResult.error !== null) {
|
|
2298
|
+
throw normalizeMessagingError(conversationResult.error, operation);
|
|
2299
|
+
}
|
|
2300
|
+
if (latestResult.error !== null) {
|
|
2301
|
+
throw normalizeMessagingError(latestResult.error, operation);
|
|
2302
|
+
}
|
|
2303
|
+
const conversation = conversationResult.data;
|
|
2304
|
+
if (conversation === null) {
|
|
2305
|
+
throw invalidResponse(operation, `conversation ${id} returned no row`);
|
|
2306
|
+
}
|
|
2307
|
+
if (typeof unreadRaw !== "number" || !Number.isFinite(unreadRaw) || unreadRaw < 0) {
|
|
2308
|
+
throw invalidResponse(operation, `${RPCS.unreadCount} returned ${JSON.stringify(unreadRaw)}`);
|
|
2309
|
+
}
|
|
2310
|
+
const participantIds = participantRows.flatMap((row) => {
|
|
2311
|
+
const value = row["user_id"];
|
|
2312
|
+
return typeof value === "string" && value.length > 0 ? [projectUserSummary({ user_id: value }).userId] : [];
|
|
2313
|
+
});
|
|
2314
|
+
const profiles = await getUsers(participantIds);
|
|
2315
|
+
const participants = participantIds.map((userId) => {
|
|
2316
|
+
const profile = profiles.get(userId);
|
|
2317
|
+
return profile === void 0 ? {
|
|
2318
|
+
user_id: userId,
|
|
2319
|
+
display_name: userId
|
|
2320
|
+
} : {
|
|
2321
|
+
user_id: profile.userId,
|
|
2322
|
+
display_name: profile.displayName,
|
|
2323
|
+
email: profile.email,
|
|
2324
|
+
avatar_url: profile.avatarUrl,
|
|
2325
|
+
is_agent: profile.isAgent
|
|
2326
|
+
};
|
|
2327
|
+
});
|
|
2328
|
+
const viewer = participantRows.find((row) => row["user_id"] === me);
|
|
2329
|
+
const latest = latestResult.data;
|
|
2330
|
+
return projectConversationSummary(
|
|
2331
|
+
{
|
|
2332
|
+
conversation_id: conversation["id"],
|
|
2333
|
+
conversation_type: conversation["type"],
|
|
2334
|
+
group_name: conversation["group_name"],
|
|
2335
|
+
group_image_url: conversation["group_image_url"],
|
|
2336
|
+
conversation_created_at: conversation["created_at"],
|
|
2337
|
+
conversation_updated_at: conversation["updated_at"],
|
|
2338
|
+
organization_id: conversation["organization_id"],
|
|
2339
|
+
created_by: conversation["created_by"],
|
|
2340
|
+
metadata: conversation["metadata"],
|
|
2341
|
+
participants,
|
|
2342
|
+
last_message_content: latest?.["content"] ?? null,
|
|
2343
|
+
last_message_sender_id: latest?.["sender_id"] ?? null,
|
|
2344
|
+
last_message_at: latest?.["created_at"] ?? null,
|
|
2345
|
+
unread_count: Math.floor(unreadRaw),
|
|
2346
|
+
is_muted: viewer?.["is_muted"] ?? false,
|
|
2347
|
+
is_archived: viewer?.["is_archived"] ?? false
|
|
2348
|
+
},
|
|
2349
|
+
me,
|
|
2350
|
+
org
|
|
2351
|
+
);
|
|
2352
|
+
},
|
|
2025
2353
|
async listMessages(conversationId, args = {}) {
|
|
2026
2354
|
const limit = args.limit ?? 50;
|
|
2027
2355
|
const operation = "listMessages";
|
|
@@ -2167,8 +2495,22 @@ function createMessagingRepository(options) {
|
|
|
2167
2495
|
async markRead(conversationId, at) {
|
|
2168
2496
|
const operation = "markRead";
|
|
2169
2497
|
const me = await actingUser(operation);
|
|
2170
|
-
|
|
2171
|
-
|
|
2498
|
+
await updateLiveParticipant(operation, conversationId, me, {
|
|
2499
|
+
last_read_at: at,
|
|
2500
|
+
updated_by: me
|
|
2501
|
+
});
|
|
2502
|
+
const unread = await withSessionRetry(
|
|
2503
|
+
operation,
|
|
2504
|
+
() => rpc(
|
|
2505
|
+
RPCS.unreadCount,
|
|
2506
|
+
{ p_conversation_id: conversationId, p_user_id: me },
|
|
2507
|
+
operation
|
|
2508
|
+
)
|
|
2509
|
+
);
|
|
2510
|
+
if (typeof unread !== "number" || !Number.isFinite(unread) || unread < 0) {
|
|
2511
|
+
throw invalidResponse(operation, `${RPCS.unreadCount} returned ${JSON.stringify(unread)}`);
|
|
2512
|
+
}
|
|
2513
|
+
return Math.floor(unread);
|
|
2172
2514
|
},
|
|
2173
2515
|
async setConversationFlags(conversationId, flags) {
|
|
2174
2516
|
const operation = "setConversationFlags";
|
|
@@ -2176,8 +2518,7 @@ function createMessagingRepository(options) {
|
|
|
2176
2518
|
const patch = { updated_by: me };
|
|
2177
2519
|
if (flags.isMuted !== void 0) patch["is_muted"] = flags.isMuted;
|
|
2178
2520
|
if (flags.isArchived !== void 0) patch["is_archived"] = flags.isArchived;
|
|
2179
|
-
|
|
2180
|
-
if (error !== null) throw normalizeMessagingError(error, operation);
|
|
2521
|
+
await updateLiveParticipant(operation, conversationId, me, patch);
|
|
2181
2522
|
},
|
|
2182
2523
|
async addMembers(conversationId, memberIds) {
|
|
2183
2524
|
const operation = "addMembers";
|
|
@@ -2197,14 +2538,18 @@ function createMessagingRepository(options) {
|
|
|
2197
2538
|
async removeMember(conversationId, memberId) {
|
|
2198
2539
|
const operation = "removeMember";
|
|
2199
2540
|
const me = await actingUser(operation);
|
|
2200
|
-
|
|
2201
|
-
|
|
2541
|
+
await updateLiveParticipant(operation, conversationId, memberId, {
|
|
2542
|
+
deleted_at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2543
|
+
updated_by: me
|
|
2544
|
+
});
|
|
2202
2545
|
},
|
|
2203
2546
|
async setMemberRole(conversationId, memberId, role) {
|
|
2204
2547
|
const operation = "setMemberRole";
|
|
2205
2548
|
const me = await actingUser(operation);
|
|
2206
|
-
|
|
2207
|
-
|
|
2549
|
+
await updateLiveParticipant(operation, conversationId, memberId, {
|
|
2550
|
+
role,
|
|
2551
|
+
updated_by: me
|
|
2552
|
+
});
|
|
2208
2553
|
},
|
|
2209
2554
|
getUser(userId) {
|
|
2210
2555
|
const operation = "getUser";
|
|
@@ -2589,7 +2934,6 @@ function useConversations() {
|
|
|
2589
2934
|
const select = (0, import_react3.useCallback)(
|
|
2590
2935
|
(id) => {
|
|
2591
2936
|
void host?.engine.openConversation(id);
|
|
2592
|
-
void host?.engine.markRead(id);
|
|
2593
2937
|
},
|
|
2594
2938
|
[host]
|
|
2595
2939
|
);
|
|
@@ -2640,7 +2984,7 @@ function useConversation(id) {
|
|
|
2640
2984
|
return () => clearInterval(timer);
|
|
2641
2985
|
}, []);
|
|
2642
2986
|
const thread = id === null ? null : snapshot?.threads.get(id) ?? null;
|
|
2643
|
-
const summary = id === null ? null : snapshot?.
|
|
2987
|
+
const summary = id === null ? null : snapshot?.conversationSummaries.get(id) ?? null;
|
|
2644
2988
|
const failure = id === null ? null : snapshot?.conversationReadFailures.get(id) ?? null;
|
|
2645
2989
|
const pending = (0, import_react3.useMemo)(() => {
|
|
2646
2990
|
void outboxTick;
|