@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.js CHANGED
@@ -182,6 +182,13 @@ function invalidResponse(operation, detail) {
182
182
  "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."
183
183
  );
184
184
  }
185
+ function writeNotApplied(operation, detail) {
186
+ return new MessagingError(
187
+ "write-not-applied",
188
+ `${operation}: ${detail}`,
189
+ "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."
190
+ );
191
+ }
185
192
 
186
193
  // src/core/references.ts
187
194
  var FENCE = /```matrx\s*\n([\s\S]*?)\n?```/g;
@@ -917,6 +924,7 @@ function insertOrdered(messages, message) {
917
924
  }
918
925
  function createMessagingStore() {
919
926
  let conversations = [];
927
+ let conversationSummaries = /* @__PURE__ */ new Map();
920
928
  let hasMoreConversations = false;
921
929
  let hasLoadedConversations = false;
922
930
  let archiveFilter = DEFAULT_MESSAGING_ARCHIVE_FILTER;
@@ -925,12 +933,15 @@ function createMessagingStore() {
925
933
  let activeConversationId = null;
926
934
  let conversationReadFailures = /* @__PURE__ */ new Map();
927
935
  let conversationsLoadError = null;
936
+ const unreadRevisions = /* @__PURE__ */ new Map();
937
+ const confirmedReadRevisions = /* @__PURE__ */ new Map();
928
938
  const listeners = /* @__PURE__ */ new Set();
929
939
  let cached = null;
930
940
  function snapshot() {
931
941
  if (cached !== null) return cached;
932
942
  cached = {
933
943
  conversations,
944
+ conversationSummaries,
934
945
  hasMoreConversations,
935
946
  hasLoadedConversations,
936
947
  threads,
@@ -949,11 +960,33 @@ function createMessagingStore() {
949
960
  listeners.forEach((listener) => listener(next));
950
961
  }
951
962
  function normalizeConversations(items) {
952
- const active = activeConversationId;
953
- const zeroed = active === null ? items : items.map(
954
- (item) => item.conversation.id === active && item.unreadCount !== 0 ? { ...item, unreadCount: 0 } : item
963
+ return [...items].sort((a, b) => a.sortAt < b.sortAt ? 1 : a.sortAt > b.sortAt ? -1 : 0);
964
+ }
965
+ function replaceConversations(next) {
966
+ const summaries = new Map(conversationSummaries);
967
+ next.forEach((item) => {
968
+ const id = item.conversation.id;
969
+ if (summaries.get(id)?.unreadCount !== item.unreadCount) {
970
+ unreadRevisions.set(id, (unreadRevisions.get(id) ?? 0) + 1);
971
+ }
972
+ summaries.set(id, item);
973
+ });
974
+ conversationSummaries = summaries;
975
+ conversations = next;
976
+ }
977
+ function replaceOneSummary(id, update) {
978
+ const held = conversationSummaries.get(id);
979
+ if (held === void 0) return;
980
+ const replacement = update(held);
981
+ if (held.unreadCount !== replacement.unreadCount) {
982
+ unreadRevisions.set(id, (unreadRevisions.get(id) ?? 0) + 1);
983
+ }
984
+ const summaries = new Map(conversationSummaries);
985
+ summaries.set(id, replacement);
986
+ conversationSummaries = summaries;
987
+ conversations = conversations.map(
988
+ (item) => item.conversation.id === id ? replacement : item
955
989
  );
956
- return [...zeroed].sort((a, b) => a.sortAt < b.sortAt ? 1 : a.sortAt > b.sortAt ? -1 : 0);
957
990
  }
958
991
  function threadFor(id) {
959
992
  return threads.get(id) ?? {
@@ -965,7 +998,9 @@ function createMessagingStore() {
965
998
  }
966
999
  function writeThread(thread) {
967
1000
  const next = new Map(threads);
968
- const latest = thread.messages.at(-1);
1001
+ const latest = [...thread.messages].reverse().find(
1002
+ (message) => message.deliveryState !== "sending" && message.deliveryState !== "failed" && !message.id.startsWith("optimistic:")
1003
+ );
969
1004
  next.set(thread.conversationId, {
970
1005
  ...thread,
971
1006
  latestAt: latest?.createdAt ?? thread.latestAt
@@ -981,7 +1016,7 @@ function createMessagingStore() {
981
1016
  };
982
1017
  },
983
1018
  setConversations(items, hasMore) {
984
- conversations = normalizeConversations(items);
1019
+ replaceConversations(normalizeConversations(items));
985
1020
  hasMoreConversations = hasMore;
986
1021
  hasLoadedConversations = true;
987
1022
  conversationsLoadError = null;
@@ -995,7 +1030,7 @@ function createMessagingStore() {
995
1030
  setArchiveFilter(next) {
996
1031
  if (next === archiveFilter) return;
997
1032
  archiveFilter = next;
998
- conversations = [];
1033
+ replaceConversations([]);
999
1034
  hasMoreConversations = false;
1000
1035
  hasLoadedConversations = false;
1001
1036
  emit();
@@ -1007,18 +1042,34 @@ function createMessagingStore() {
1007
1042
  appendConversations(items, hasMore) {
1008
1043
  const byId = new Map(conversations.map((item) => [item.conversation.id, item]));
1009
1044
  items.forEach((item) => byId.set(item.conversation.id, item));
1010
- conversations = normalizeConversations([...byId.values()]);
1045
+ replaceConversations(normalizeConversations([...byId.values()]));
1011
1046
  hasMoreConversations = hasMore;
1012
1047
  emit();
1013
1048
  },
1014
1049
  upsertConversation(item) {
1015
1050
  const byId = new Map(conversations.map((entry) => [entry.conversation.id, entry]));
1016
1051
  byId.set(item.conversation.id, item);
1017
- conversations = normalizeConversations([...byId.values()]);
1052
+ replaceConversations(normalizeConversations([...byId.values()]));
1053
+ emit();
1054
+ },
1055
+ setConversationSummary(item) {
1056
+ const id = item.conversation.id;
1057
+ const held = conversationSummaries.get(id);
1058
+ if (held?.unreadCount !== item.unreadCount) {
1059
+ unreadRevisions.set(id, (unreadRevisions.get(id) ?? 0) + 1);
1060
+ }
1061
+ const summaries = new Map(conversationSummaries);
1062
+ summaries.set(id, item);
1063
+ conversationSummaries = summaries;
1018
1064
  emit();
1019
1065
  },
1020
1066
  removeConversation(id) {
1021
- conversations = conversations.filter((item) => item.conversation.id !== id);
1067
+ replaceConversations(conversations.filter((item) => item.conversation.id !== id));
1068
+ const summaries = new Map(conversationSummaries);
1069
+ summaries.delete(id);
1070
+ conversationSummaries = summaries;
1071
+ unreadRevisions.delete(id);
1072
+ confirmedReadRevisions.delete(id);
1022
1073
  const next = new Map(threads);
1023
1074
  next.delete(id);
1024
1075
  threads = next;
@@ -1026,7 +1077,6 @@ function createMessagingStore() {
1026
1077
  },
1027
1078
  setActiveConversation(id) {
1028
1079
  activeConversationId = id;
1029
- conversations = normalizeConversations(conversations);
1030
1080
  emit();
1031
1081
  },
1032
1082
  setConversationReadFailure(id, failure) {
@@ -1046,6 +1096,13 @@ function createMessagingStore() {
1046
1096
  });
1047
1097
  emit();
1048
1098
  },
1099
+ clearThread(id) {
1100
+ if (!threads.has(id)) return;
1101
+ const next = new Map(threads);
1102
+ next.delete(id);
1103
+ threads = next;
1104
+ emit();
1105
+ },
1049
1106
  prependOlder(id, messages, hasMoreOlder) {
1050
1107
  const thread = threadFor(id);
1051
1108
  const known = new Set(thread.messages.map((message) => message.id));
@@ -1098,40 +1155,48 @@ function createMessagingStore() {
1098
1155
  emit();
1099
1156
  },
1100
1157
  applyLastMessage(message, args = {}) {
1101
- const existing = conversations.find(
1102
- (item) => item.conversation.id === message.conversationId
1103
- );
1158
+ const existing = conversationSummaries.get(message.conversationId);
1104
1159
  if (existing === void 0) return;
1105
1160
  if (message.deliveryState === "sending" || message.deletedAt !== null) return;
1106
1161
  if (existing.lastMessageAt !== null && message.createdAt < existing.lastMessageAt) return;
1107
- const isActive = activeConversationId === message.conversationId;
1108
- const shouldCount = args.incrementUnread === true && !isActive;
1109
- conversations = normalizeConversations(
1110
- conversations.map(
1111
- (item) => item.conversation.id === message.conversationId ? {
1112
- ...item,
1113
- lastMessageContent: message.content,
1114
- lastMessageSenderId: message.senderId,
1115
- lastMessageAt: message.createdAt,
1116
- sortAt: message.createdAt,
1117
- unreadCount: shouldCount ? item.unreadCount + 1 : item.unreadCount
1118
- } : item
1119
- )
1120
- );
1162
+ const shouldCount = args.incrementUnread === true;
1163
+ replaceOneSummary(message.conversationId, (item) => ({
1164
+ ...item,
1165
+ lastMessageContent: message.content,
1166
+ lastMessageSenderId: message.senderId,
1167
+ lastMessageAt: message.createdAt,
1168
+ sortAt: message.createdAt,
1169
+ unreadCount: shouldCount ? item.unreadCount + 1 : item.unreadCount
1170
+ }));
1171
+ conversations = normalizeConversations(conversations);
1121
1172
  emit();
1122
1173
  },
1123
- markConversationRead(id) {
1124
- conversations = conversations.map(
1125
- (item) => item.conversation.id === id ? { ...item, unreadCount: 0 } : item
1126
- );
1174
+ captureConversationRead(id, at) {
1175
+ const item = conversationSummaries.get(id);
1176
+ return {
1177
+ at,
1178
+ unreadCount: item?.unreadCount ?? 0,
1179
+ revision: unreadRevisions.get(id) ?? 0
1180
+ };
1181
+ },
1182
+ markConversationRead(id, cutoff, canonicalUnreadCount = 0) {
1183
+ if (cutoff.revision <= (confirmedReadRevisions.get(id) ?? -1)) return;
1184
+ confirmedReadRevisions.set(id, cutoff.revision);
1185
+ replaceOneSummary(id, (item) => ({
1186
+ ...item,
1187
+ // The repository re-counts after persisting the exact server cutoff.
1188
+ // If realtime advanced local state while that response was in flight,
1189
+ // retain at least the arrivals beyond the captured baseline.
1190
+ unreadCount: Math.max(
1191
+ 0,
1192
+ canonicalUnreadCount,
1193
+ item.unreadCount - cutoff.unreadCount
1194
+ )
1195
+ }));
1127
1196
  emit();
1128
1197
  },
1129
1198
  setUnreadCount(id, count) {
1130
- conversations = normalizeConversations(
1131
- conversations.map(
1132
- (item) => item.conversation.id === id ? { ...item, unreadCount: Math.max(0, count) } : item
1133
- )
1134
- );
1199
+ replaceOneSummary(id, (item) => ({ ...item, unreadCount: Math.max(0, count) }));
1135
1200
  emit();
1136
1201
  }
1137
1202
  };
@@ -1168,21 +1233,57 @@ function createMessagingEngine(options) {
1168
1233
  const conversationPageSize = options.conversationPageSize ?? 30;
1169
1234
  const messagePageSize = options.messagePageSize ?? 50;
1170
1235
  const openChannels = /* @__PURE__ */ new Map();
1236
+ const channelReadiness = /* @__PURE__ */ new Map();
1237
+ const settleChannelReadiness = /* @__PURE__ */ new Map();
1238
+ const readReceiptTails = /* @__PURE__ */ new Map();
1239
+ const persistedReadAt = /* @__PURE__ */ new Map();
1240
+ const initialReadDepth = /* @__PURE__ */ new Map();
1241
+ const initialReadGeneration = /* @__PURE__ */ new Map();
1242
+ const initialReadChanges = /* @__PURE__ */ new Map();
1171
1243
  let inboxChannel = null;
1172
1244
  let conversationCursor = null;
1173
1245
  let disposed = false;
1174
1246
  function report(event) {
1175
1247
  options.onDiagnostic?.(event);
1176
1248
  }
1177
- function reportError(error, operation) {
1249
+ function reportError(error, operation, context = {}) {
1178
1250
  const normalized = normalizeMessagingError(error, operation);
1179
1251
  report({
1180
1252
  // A missing session is a NORMAL lifecycle moment, not a red error. This
1181
1253
  // one line is the cure for "909 captured errors in 0.6s".
1182
1254
  level: normalized.code === "session-unavailable" ? "warn" : "error",
1183
1255
  message: normalized.message,
1184
- remedy: normalized.remedy
1256
+ remedy: normalized.remedy,
1257
+ code: normalized.code,
1258
+ operation,
1259
+ isRetryable: normalized.isRetryable,
1260
+ error: normalized,
1261
+ ...context
1185
1262
  });
1263
+ return normalized;
1264
+ }
1265
+ function persistRead(id, isLive = () => true) {
1266
+ const run = async () => {
1267
+ if (!isLive()) return;
1268
+ const at = store.snapshot().threads.get(id)?.latestAt ?? null;
1269
+ if (at === null || (persistedReadAt.get(id) ?? "") >= at) return;
1270
+ const cutoff = store.captureConversationRead(id, at);
1271
+ try {
1272
+ const canonicalUnreadCount = await repository.markRead(id, at);
1273
+ if (!isLive()) return;
1274
+ persistedReadAt.set(id, at);
1275
+ store.markConversationRead(id, cutoff, canonicalUnreadCount);
1276
+ } catch (error) {
1277
+ throw reportError(error, "markRead", { conversationId: id });
1278
+ }
1279
+ };
1280
+ const previous = readReceiptTails.get(id);
1281
+ const current = previous === void 0 ? run() : previous.catch(() => void 0).then(run);
1282
+ readReceiptTails.set(id, current);
1283
+ void current.finally(() => {
1284
+ if (readReceiptTails.get(id) === current) readReceiptTails.delete(id);
1285
+ }).catch(() => void 0);
1286
+ return current;
1186
1287
  }
1187
1288
  const outbox = createOutbox({
1188
1289
  ...options.outboxStorage !== void 0 ? { storage: options.outboxStorage } : {},
@@ -1267,6 +1368,137 @@ function createMessagingEngine(options) {
1267
1368
  reportError(error, "backfillConversation");
1268
1369
  }
1269
1370
  }
1371
+ function applyLiveMessageChange(id, change) {
1372
+ if ((initialReadDepth.get(id) ?? 0) > 0) {
1373
+ const changes = initialReadChanges.get(id) ?? [];
1374
+ changes.push(change);
1375
+ initialReadChanges.set(id, changes);
1376
+ }
1377
+ if (change.kind === "delete") {
1378
+ store.removeMessage(id, change.messageId);
1379
+ } else {
1380
+ store.ingest(change.message);
1381
+ }
1382
+ }
1383
+ function beginInitialRead(id) {
1384
+ const generation = (initialReadGeneration.get(id) ?? 0) + 1;
1385
+ initialReadGeneration.set(id, generation);
1386
+ initialReadDepth.set(id, (initialReadDepth.get(id) ?? 0) + 1);
1387
+ initialReadChanges.set(id, initialReadChanges.get(id) ?? []);
1388
+ return generation;
1389
+ }
1390
+ function finishInitialRead(id) {
1391
+ const depth = (initialReadDepth.get(id) ?? 1) - 1;
1392
+ if (depth > 0) {
1393
+ initialReadDepth.set(id, depth);
1394
+ return;
1395
+ }
1396
+ initialReadDepth.delete(id);
1397
+ initialReadChanges.delete(id);
1398
+ }
1399
+ function openIsLive(id, generation, channel) {
1400
+ return !disposed && initialReadGeneration.get(id) === generation && channel !== null && openChannels.get(id) === channel;
1401
+ }
1402
+ function installInitialPage(id, generation, page) {
1403
+ if (initialReadGeneration.get(id) !== generation) return false;
1404
+ const optimistic = (store.snapshot().threads.get(id)?.messages ?? []).filter(
1405
+ (item) => item.deliveryState === "sending" || item.deliveryState === "failed"
1406
+ );
1407
+ const confirmedClientKeys = new Set(
1408
+ page.items.flatMap(
1409
+ (item) => item.clientMessageId === null ? [] : [item.clientMessageId]
1410
+ )
1411
+ );
1412
+ store.setThread(id, page.items, { hasMoreOlder: page.hasMore });
1413
+ for (const change of initialReadChanges.get(id) ?? []) {
1414
+ if (change.kind === "delete") {
1415
+ store.removeMessage(id, change.messageId);
1416
+ } else {
1417
+ store.ingest(change.message);
1418
+ }
1419
+ }
1420
+ store.ingestMany(
1421
+ optimistic.filter(
1422
+ (item) => item.clientMessageId === null || !confirmedClientKeys.has(item.clientMessageId)
1423
+ )
1424
+ );
1425
+ return true;
1426
+ }
1427
+ function ensureConversationChannel(id) {
1428
+ const existing = openChannels.get(id);
1429
+ if (existing !== void 0) {
1430
+ return channelReadiness.get(id) ?? Promise.resolve(existing.status() === "connected");
1431
+ }
1432
+ let markReady;
1433
+ const ready = new Promise((resolve) => {
1434
+ markReady = resolve;
1435
+ });
1436
+ const handle = manager.open({
1437
+ topic: conversationTopic(id),
1438
+ // Presence callbacks are JOIN-TIME in supabase-js. Declare the read-only
1439
+ // room shape on the first holder so later holders do not create a gap.
1440
+ presence: { publish: false, state: {} },
1441
+ postgresChanges: [
1442
+ {
1443
+ event: "*",
1444
+ schema: "communication",
1445
+ table: "dm_messages",
1446
+ filter: `conversation_id=eq.${id}`,
1447
+ rowId: (row) => typeof row["id"] === "string" ? row["id"] : void 0,
1448
+ fingerprint: (row) => typeof row["content"] === "string" ? row["content"] : void 0,
1449
+ updatedAtField: "updated_at",
1450
+ updatedByField: "updated_by",
1451
+ onChange: ({ payload, row }) => {
1452
+ if (row === null) return;
1453
+ if (payload?.eventType === "DELETE" || typeof row["deleted_at"] === "string" && row["deleted_at"].length > 0) {
1454
+ const rawId = row["id"];
1455
+ if (typeof rawId !== "string" || rawId.length === 0) {
1456
+ reportError(
1457
+ new Error("A message deletion payload carried no message id."),
1458
+ "conversationMessageDelete",
1459
+ { conversationId: id }
1460
+ );
1461
+ return;
1462
+ }
1463
+ applyLiveMessageChange(id, {
1464
+ kind: "delete",
1465
+ messageId: asMessageId(rawId)
1466
+ });
1467
+ return;
1468
+ }
1469
+ const message = projectMessage(row, identity.organizationId);
1470
+ applyLiveMessageChange(id, { kind: "upsert", message });
1471
+ }
1472
+ }
1473
+ ],
1474
+ broadcast: [
1475
+ {
1476
+ event: MESSAGING_EVENTS.message,
1477
+ onMessage: ({ data }) => {
1478
+ try {
1479
+ const message = projectBroadcastMessage(data);
1480
+ applyLiveMessageChange(id, { kind: "upsert", message });
1481
+ } catch (error) {
1482
+ reportError(error, "broadcastMessage");
1483
+ }
1484
+ }
1485
+ }
1486
+ ],
1487
+ onStatusChange: (status) => {
1488
+ if (status === "connected") markReady?.(true);
1489
+ if (status === "disconnected") markReady?.(false);
1490
+ },
1491
+ onBackfill: () => {
1492
+ void backfillConversation(id);
1493
+ outbox.flush();
1494
+ }
1495
+ });
1496
+ openChannels.set(id, handle);
1497
+ channelReadiness.set(id, ready);
1498
+ settleChannelReadiness.set(id, (connected) => markReady?.(connected));
1499
+ if (handle.status() === "connected") markReady?.(true);
1500
+ return ready;
1501
+ }
1270
1502
  const engine = {
1271
1503
  store,
1272
1504
  outbox,
@@ -1292,7 +1524,9 @@ function createMessagingEngine(options) {
1292
1524
  onChange: ({ row }) => {
1293
1525
  if (row === null) return;
1294
1526
  const message = projectMessage(row, identity.organizationId);
1295
- const known = store.snapshot().conversations.some((item) => item.conversation.id === message.conversationId);
1527
+ const known = store.snapshot().conversationSummaries.has(
1528
+ message.conversationId
1529
+ );
1296
1530
  if (!known) {
1297
1531
  void reloadInbox().catch((error) => reportError(error, "inboxRefresh"));
1298
1532
  return;
@@ -1300,6 +1534,9 @@ function createMessagingEngine(options) {
1300
1534
  store.ingest(message);
1301
1535
  const isMine = message.senderId === identity.userId;
1302
1536
  store.applyLastMessage(message, { incrementUnread: !isMine });
1537
+ if (!isMine && store.snapshot().activeConversationId === message.conversationId) {
1538
+ void persistRead(message.conversationId).catch(() => void 0);
1539
+ }
1303
1540
  if (!isMine) options.onIncoming?.(message);
1304
1541
  }
1305
1542
  },
@@ -1364,18 +1601,35 @@ function createMessagingEngine(options) {
1364
1601
  },
1365
1602
  async openConversation(id) {
1366
1603
  store.setActiveConversation(id);
1604
+ const generation = beginInitialRead(id);
1605
+ let ownedChannel = null;
1367
1606
  try {
1607
+ const connected = await ensureConversationChannel(id);
1608
+ ownedChannel = openChannels.get(id) ?? null;
1609
+ if (!connected || !openIsLive(id, generation, ownedChannel)) return;
1368
1610
  const page = await repository.listMessages(id, { limit: messagePageSize });
1369
- if (page.items.length === 0) {
1370
- await repository.getConversation(id);
1371
- }
1611
+ if (!openIsLive(id, generation, ownedChannel)) return;
1612
+ if (!installInitialPage(id, generation, page)) return;
1613
+ const missingSummary = !store.snapshot().conversationSummaries.has(id);
1614
+ const directSummary = missingSummary ? await repository.getConversationSummary(id) : null;
1615
+ if (!openIsLive(id, generation, ownedChannel)) return;
1372
1616
  store.setConversationReadFailure(id, null);
1373
- store.setThread(id, page.items, { hasMoreOlder: page.hasMore });
1617
+ if (directSummary !== null) store.setConversationSummary(directSummary);
1618
+ await persistRead(
1619
+ id,
1620
+ () => openIsLive(id, generation, ownedChannel)
1621
+ ).catch(() => void 0);
1374
1622
  } catch (error) {
1623
+ if (!openIsLive(id, generation, ownedChannel)) return;
1375
1624
  const normalized = normalizeMessagingError(error, "openConversation");
1376
1625
  const unavailable = normalized.code === "not-found" || normalized.code === "forbidden";
1377
1626
  store.setConversationReadFailure(id, { error: normalized, unavailable });
1378
1627
  if (unavailable) {
1628
+ openChannels.get(id)?.close();
1629
+ openChannels.delete(id);
1630
+ settleChannelReadiness.delete(id);
1631
+ channelReadiness.delete(id);
1632
+ store.clearThread(id);
1379
1633
  report({
1380
1634
  level: "warn",
1381
1635
  message: normalized.message,
@@ -1384,53 +1638,17 @@ function createMessagingEngine(options) {
1384
1638
  return;
1385
1639
  }
1386
1640
  reportError(normalized, "openConversation");
1641
+ } finally {
1642
+ finishInitialRead(id);
1387
1643
  }
1388
- if (openChannels.has(id) || disposed) return;
1389
- const handle = manager.open({
1390
- topic: conversationTopic(id),
1391
- // Presence callbacks are JOIN-TIME in supabase-js. Declare the
1392
- // read-only room shape on the first holder (the engine) so the
1393
- // composer/typist/online hooks can attach without forcing a rebuild
1394
- // after this durable channel has already subscribed.
1395
- presence: { publish: false, state: {} },
1396
- postgresChanges: [
1397
- {
1398
- event: "*",
1399
- schema: "communication",
1400
- table: "dm_messages",
1401
- filter: `conversation_id=eq.${id}`,
1402
- rowId: (row) => typeof row["id"] === "string" ? row["id"] : void 0,
1403
- fingerprint: (row) => typeof row["content"] === "string" ? row["content"] : void 0,
1404
- updatedAtField: "updated_at",
1405
- updatedByField: "updated_by",
1406
- onChange: ({ row }) => {
1407
- if (row === null) return;
1408
- store.ingest(projectMessage(row, identity.organizationId));
1409
- }
1410
- }
1411
- ],
1412
- broadcast: [
1413
- {
1414
- event: MESSAGING_EVENTS.message,
1415
- onMessage: ({ data }) => {
1416
- try {
1417
- store.ingest(projectBroadcastMessage(data));
1418
- } catch (error) {
1419
- reportError(error, "broadcastMessage");
1420
- }
1421
- }
1422
- }
1423
- ],
1424
- onBackfill: () => {
1425
- void backfillConversation(id);
1426
- outbox.flush();
1427
- }
1428
- });
1429
- openChannels.set(id, handle);
1430
1644
  },
1431
1645
  closeConversation(id) {
1646
+ initialReadGeneration.set(id, (initialReadGeneration.get(id) ?? 0) + 1);
1432
1647
  openChannels.get(id)?.close();
1433
1648
  openChannels.delete(id);
1649
+ settleChannelReadiness.get(id)?.(false);
1650
+ settleChannelReadiness.delete(id);
1651
+ channelReadiness.delete(id);
1434
1652
  if (store.snapshot().activeConversationId === id) {
1435
1653
  store.setActiveConversation(null);
1436
1654
  }
@@ -1482,12 +1700,7 @@ function createMessagingEngine(options) {
1482
1700
  }
1483
1701
  },
1484
1702
  async markRead(id) {
1485
- store.markConversationRead(id);
1486
- try {
1487
- await repository.markRead(id);
1488
- } catch (error) {
1489
- reportError(error, "markRead");
1490
- }
1703
+ await persistRead(id);
1491
1704
  },
1492
1705
  async startDirectConversation(otherUserId) {
1493
1706
  const id = await repository.getOrCreateDirectConversation(otherUserId);
@@ -1496,9 +1709,15 @@ function createMessagingEngine(options) {
1496
1709
  },
1497
1710
  dispose() {
1498
1711
  disposed = true;
1712
+ openChannels.forEach((_handle, id) => {
1713
+ initialReadGeneration.set(id, (initialReadGeneration.get(id) ?? 0) + 1);
1714
+ });
1499
1715
  outbox.dispose();
1500
1716
  openChannels.forEach((handle) => handle.close());
1501
1717
  openChannels.clear();
1718
+ settleChannelReadiness.forEach((settle) => settle(false));
1719
+ settleChannelReadiness.clear();
1720
+ channelReadiness.clear();
1502
1721
  inboxChannel?.close();
1503
1722
  inboxChannel = null;
1504
1723
  }
@@ -1712,6 +1931,7 @@ var RPCS = {
1712
1931
  isParticipant: "is_dm_participant"
1713
1932
  };
1714
1933
  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";
1934
+ var PARTICIPANT_COLUMNS = "conversation_id,user_id,role,joined_at,last_read_at,is_muted,is_archived";
1715
1935
  var CONVERSATION_COLUMNS = "id,type,group_name,group_image_url,created_by,organization_id,created_at,updated_at,metadata";
1716
1936
  function optionalOrg(organizationId) {
1717
1937
  return typeof organizationId === "string" && organizationId.length > 0 ? organizationId : null;
@@ -1781,6 +2001,14 @@ function createMessagingRepository(options) {
1781
2001
  function actingUser(operation) {
1782
2002
  return withSessionRetry(operation, () => actingUserId());
1783
2003
  }
2004
+ async function updateLiveParticipant(operation, conversationId, userId, patch) {
2005
+ const destination = await organizationOfExisting(conversationId);
2006
+ 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();
2007
+ if (error !== null) throw normalizeMessagingError(error, operation);
2008
+ if (data === null) {
2009
+ throw writeNotApplied(operation, "participant update returned no live row");
2010
+ }
2011
+ }
1784
2012
  async function rpc(fn, args, operation) {
1785
2013
  const { data, error } = await rpcDb().rpc(fn, args);
1786
2014
  if (error !== null) throw normalizeMessagingError(error, operation);
@@ -1803,6 +2031,33 @@ function createMessagingRepository(options) {
1803
2031
  });
1804
2032
  return found;
1805
2033
  }
2034
+ async function allLiveParticipants(conversationId) {
2035
+ const operation = "getConversationSummary";
2036
+ const pageSize = 1e3;
2037
+ const rows = [];
2038
+ for (let from = 0; ; from += pageSize) {
2039
+ const response = await withSessionRetry(
2040
+ operation,
2041
+ 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)
2042
+ );
2043
+ if (response.error !== null) {
2044
+ throw normalizeMessagingError(response.error, operation);
2045
+ }
2046
+ const total = response.count;
2047
+ if (typeof total !== "number" || !Number.isInteger(total) || total < 0) {
2048
+ throw invalidResponse(operation, "participant read returned no exact count");
2049
+ }
2050
+ const page = response.data ?? [];
2051
+ rows.push(...page);
2052
+ if (rows.length >= total) return rows;
2053
+ if (page.length === 0) {
2054
+ throw invalidResponse(
2055
+ operation,
2056
+ `participant read stopped at ${rows.length} of ${total}`
2057
+ );
2058
+ }
2059
+ }
2060
+ }
1806
2061
  const conversationOrganizations = /* @__PURE__ */ new Map();
1807
2062
  async function organizationOfExisting(conversationId) {
1808
2063
  const known = conversationOrganizations.get(conversationId);
@@ -1914,6 +2169,78 @@ function createMessagingRepository(options) {
1914
2169
  );
1915
2170
  return summary.conversation;
1916
2171
  },
2172
+ async getConversationSummary(id) {
2173
+ const operation = "getConversationSummary";
2174
+ const me = await actingUser(operation);
2175
+ const [conversationResult, participantRows, latestResult, unreadRaw] = await withSessionRetry(
2176
+ operation,
2177
+ () => Promise.all([
2178
+ db().from(TABLES.conversations).select(CONVERSATION_COLUMNS).eq("id", id).single(),
2179
+ allLiveParticipants(id),
2180
+ 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(),
2181
+ rpc(
2182
+ RPCS.unreadCount,
2183
+ { p_conversation_id: id, p_user_id: me },
2184
+ operation
2185
+ )
2186
+ ])
2187
+ );
2188
+ if (conversationResult.error !== null) {
2189
+ throw normalizeMessagingError(conversationResult.error, operation);
2190
+ }
2191
+ if (latestResult.error !== null) {
2192
+ throw normalizeMessagingError(latestResult.error, operation);
2193
+ }
2194
+ const conversation = conversationResult.data;
2195
+ if (conversation === null) {
2196
+ throw invalidResponse(operation, `conversation ${id} returned no row`);
2197
+ }
2198
+ if (typeof unreadRaw !== "number" || !Number.isFinite(unreadRaw) || unreadRaw < 0) {
2199
+ throw invalidResponse(operation, `${RPCS.unreadCount} returned ${JSON.stringify(unreadRaw)}`);
2200
+ }
2201
+ const participantIds = participantRows.flatMap((row) => {
2202
+ const value = row["user_id"];
2203
+ return typeof value === "string" && value.length > 0 ? [projectUserSummary({ user_id: value }).userId] : [];
2204
+ });
2205
+ const profiles = await getUsers(participantIds);
2206
+ const participants = participantIds.map((userId) => {
2207
+ const profile = profiles.get(userId);
2208
+ return profile === void 0 ? {
2209
+ user_id: userId,
2210
+ display_name: userId
2211
+ } : {
2212
+ user_id: profile.userId,
2213
+ display_name: profile.displayName,
2214
+ email: profile.email,
2215
+ avatar_url: profile.avatarUrl,
2216
+ is_agent: profile.isAgent
2217
+ };
2218
+ });
2219
+ const viewer = participantRows.find((row) => row["user_id"] === me);
2220
+ const latest = latestResult.data;
2221
+ return projectConversationSummary(
2222
+ {
2223
+ conversation_id: conversation["id"],
2224
+ conversation_type: conversation["type"],
2225
+ group_name: conversation["group_name"],
2226
+ group_image_url: conversation["group_image_url"],
2227
+ conversation_created_at: conversation["created_at"],
2228
+ conversation_updated_at: conversation["updated_at"],
2229
+ organization_id: conversation["organization_id"],
2230
+ created_by: conversation["created_by"],
2231
+ metadata: conversation["metadata"],
2232
+ participants,
2233
+ last_message_content: latest?.["content"] ?? null,
2234
+ last_message_sender_id: latest?.["sender_id"] ?? null,
2235
+ last_message_at: latest?.["created_at"] ?? null,
2236
+ unread_count: Math.floor(unreadRaw),
2237
+ is_muted: viewer?.["is_muted"] ?? false,
2238
+ is_archived: viewer?.["is_archived"] ?? false
2239
+ },
2240
+ me,
2241
+ org
2242
+ );
2243
+ },
1917
2244
  async listMessages(conversationId, args = {}) {
1918
2245
  const limit = args.limit ?? 50;
1919
2246
  const operation = "listMessages";
@@ -2059,8 +2386,22 @@ function createMessagingRepository(options) {
2059
2386
  async markRead(conversationId, at) {
2060
2387
  const operation = "markRead";
2061
2388
  const me = await actingUser(operation);
2062
- 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);
2063
- if (error !== null) throw normalizeMessagingError(error, operation);
2389
+ await updateLiveParticipant(operation, conversationId, me, {
2390
+ last_read_at: at,
2391
+ updated_by: me
2392
+ });
2393
+ const unread = await withSessionRetry(
2394
+ operation,
2395
+ () => rpc(
2396
+ RPCS.unreadCount,
2397
+ { p_conversation_id: conversationId, p_user_id: me },
2398
+ operation
2399
+ )
2400
+ );
2401
+ if (typeof unread !== "number" || !Number.isFinite(unread) || unread < 0) {
2402
+ throw invalidResponse(operation, `${RPCS.unreadCount} returned ${JSON.stringify(unread)}`);
2403
+ }
2404
+ return Math.floor(unread);
2064
2405
  },
2065
2406
  async setConversationFlags(conversationId, flags) {
2066
2407
  const operation = "setConversationFlags";
@@ -2068,8 +2409,7 @@ function createMessagingRepository(options) {
2068
2409
  const patch = { updated_by: me };
2069
2410
  if (flags.isMuted !== void 0) patch["is_muted"] = flags.isMuted;
2070
2411
  if (flags.isArchived !== void 0) patch["is_archived"] = flags.isArchived;
2071
- const { error } = await db().from(TABLES.participants).update(patch).eq("conversation_id", conversationId).eq("user_id", me);
2072
- if (error !== null) throw normalizeMessagingError(error, operation);
2412
+ await updateLiveParticipant(operation, conversationId, me, patch);
2073
2413
  },
2074
2414
  async addMembers(conversationId, memberIds) {
2075
2415
  const operation = "addMembers";
@@ -2089,14 +2429,18 @@ function createMessagingRepository(options) {
2089
2429
  async removeMember(conversationId, memberId) {
2090
2430
  const operation = "removeMember";
2091
2431
  const me = await actingUser(operation);
2092
- 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);
2093
- if (error !== null) throw normalizeMessagingError(error, operation);
2432
+ await updateLiveParticipant(operation, conversationId, memberId, {
2433
+ deleted_at: (/* @__PURE__ */ new Date()).toISOString(),
2434
+ updated_by: me
2435
+ });
2094
2436
  },
2095
2437
  async setMemberRole(conversationId, memberId, role) {
2096
2438
  const operation = "setMemberRole";
2097
2439
  const me = await actingUser(operation);
2098
- const { error } = await db().from(TABLES.participants).update({ role, updated_by: me }).eq("conversation_id", conversationId).eq("user_id", memberId);
2099
- if (error !== null) throw normalizeMessagingError(error, operation);
2440
+ await updateLiveParticipant(operation, conversationId, memberId, {
2441
+ role,
2442
+ updated_by: me
2443
+ });
2100
2444
  },
2101
2445
  getUser(userId) {
2102
2446
  const operation = "getUser";
@@ -2481,7 +2825,6 @@ function useConversations() {
2481
2825
  const select = useCallback(
2482
2826
  (id) => {
2483
2827
  void host?.engine.openConversation(id);
2484
- void host?.engine.markRead(id);
2485
2828
  },
2486
2829
  [host]
2487
2830
  );
@@ -2532,7 +2875,7 @@ function useConversation(id) {
2532
2875
  return () => clearInterval(timer);
2533
2876
  }, []);
2534
2877
  const thread = id === null ? null : snapshot?.threads.get(id) ?? null;
2535
- const summary = id === null ? null : snapshot?.conversations.find((item) => item.conversation.id === id) ?? null;
2878
+ const summary = id === null ? null : snapshot?.conversationSummaries.get(id) ?? null;
2536
2879
  const failure = id === null ? null : snapshot?.conversationReadFailures.get(id) ?? null;
2537
2880
  const pending = useMemo2(() => {
2538
2881
  void outboxTick;
@@ -4043,6 +4386,7 @@ export {
4043
4386
  useMessagingSnapshot,
4044
4387
  useOnlineUserIds,
4045
4388
  useRequiredMessagingHost,
4046
- useTypists
4389
+ useTypists,
4390
+ writeNotApplied
4047
4391
  };
4048
4392
  //# sourceMappingURL=react.js.map