@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/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
- const active = activeConversationId;
1061
- const zeroed = active === null ? items : items.map(
1062
- (item) => item.conversation.id === active && item.unreadCount !== 0 ? { ...item, unreadCount: 0 } : item
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.at(-1);
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
- conversations = normalizeConversations(items);
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
- conversations = [];
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
- conversations = normalizeConversations([...byId.values()]);
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
- conversations = normalizeConversations([...byId.values()]);
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 = conversations.filter((item) => item.conversation.id !== id);
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 = conversations.find(
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 isActive = activeConversationId === message.conversationId;
1216
- const shouldCount = args.incrementUnread === true && !isActive;
1217
- conversations = normalizeConversations(
1218
- conversations.map(
1219
- (item) => item.conversation.id === message.conversationId ? {
1220
- ...item,
1221
- lastMessageContent: message.content,
1222
- lastMessageSenderId: message.senderId,
1223
- lastMessageAt: message.createdAt,
1224
- sortAt: message.createdAt,
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
- markConversationRead(id) {
1232
- conversations = conversations.map(
1233
- (item) => item.conversation.id === id ? { ...item, unreadCount: 0 } : item
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
- conversations = normalizeConversations(
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().conversations.some((item) => item.conversation.id === message.conversationId);
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 (page.items.length === 0) {
1478
- await repository.getConversation(id);
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
- store.setThread(id, page.items, { hasMoreOlder: page.hasMore });
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
- store.markConversationRead(id);
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
- const { error } = await db().from(TABLES.participants).update({ last_read_at: at ?? (/* @__PURE__ */ new Date()).toISOString(), updated_by: me }).eq("conversation_id", conversationId).eq("user_id", me);
2171
- if (error !== null) throw normalizeMessagingError(error, operation);
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
- const { error } = await db().from(TABLES.participants).update(patch).eq("conversation_id", conversationId).eq("user_id", me);
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
- const { error } = await db().from(TABLES.participants).update({ deleted_at: (/* @__PURE__ */ new Date()).toISOString(), updated_by: me }).eq("conversation_id", conversationId).eq("user_id", memberId);
2201
- if (error !== null) throw normalizeMessagingError(error, operation);
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
- const { error } = await db().from(TABLES.participants).update({ role, updated_by: me }).eq("conversation_id", conversationId).eq("user_id", memberId);
2207
- if (error !== null) throw normalizeMessagingError(error, operation);
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?.conversations.find((item) => item.conversation.id === id) ?? null;
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;