@ai-matrx/messaging 0.15.82 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/react.cjs CHANGED
@@ -97,6 +97,7 @@ __export(react_exports, {
97
97
  groupMessages: () => groupMessages,
98
98
  inboxTopic: () => inboxTopic,
99
99
  invalidResponse: () => invalidResponse,
100
+ isLapsed: () => isLapsed,
100
101
  isSameDay: () => isSameDay,
101
102
  messagingClientId: () => messagingClientId,
102
103
  normalizeMessagingError: () => normalizeMessagingError,
@@ -108,6 +109,7 @@ __export(react_exports, {
108
109
  projectParticipantRole: () => projectParticipantRole,
109
110
  projectUserSummary: () => projectUserSummary,
110
111
  resolveActor: () => resolveActor,
112
+ softExpiryTime: () => softExpiryTime,
111
113
  splitText: () => splitText,
112
114
  summarizeText: () => summarizeText,
113
115
  toMessagingArchiveFilter: () => toMessagingArchiveFilter,
@@ -911,7 +913,8 @@ function projectBroadcastMessage(value) {
911
913
  action: projectMessageAction(record["action"]),
912
914
  attachments: projectAttachmentList(record["attachments"]),
913
915
  references: projectReferenceList(record["references"]),
914
- metadata
916
+ metadata,
917
+ softExpiresAt: str(record, "softExpiresAt")
915
918
  };
916
919
  }
917
920
  function projectMessage(row, fallbackOrganizationId) {
@@ -941,7 +944,8 @@ function projectMessage(row, fallbackOrganizationId) {
941
944
  action,
942
945
  attachments: projectAttachments(metadata),
943
946
  references: projectReferences(metadata),
944
- metadata
947
+ metadata,
948
+ softExpiresAt: str(row, "soft_expires_at")
945
949
  };
946
950
  }
947
951
  function projectParticipantRole(value) {
@@ -994,6 +998,18 @@ function projectConversationSummary(row, viewerId, fallbackOrganizationId) {
994
998
  };
995
999
  }
996
1000
 
1001
+ // src/core/lapse.ts
1002
+ function softExpiryTime(message) {
1003
+ if (message.softExpiresAt === null) return null;
1004
+ const parsed = Date.parse(message.softExpiresAt);
1005
+ return Number.isFinite(parsed) ? parsed : null;
1006
+ }
1007
+ function isLapsed(message, now = Date.now()) {
1008
+ const at = softExpiryTime(message);
1009
+ if (at === null) return false;
1010
+ return at <= (typeof now === "number" ? now : now.getTime());
1011
+ }
1012
+
997
1013
  // src/core/types.ts
998
1014
  var asConversationId = (value) => value;
999
1015
  var asMessageId = (value) => value;
@@ -1031,7 +1047,8 @@ function insertOrdered(messages, message) {
1031
1047
  next.splice(index, 0, message);
1032
1048
  return next;
1033
1049
  }
1034
- function createMessagingStore() {
1050
+ function createMessagingStore(options = {}) {
1051
+ const now = options.now ?? Date.now;
1035
1052
  let conversations = [];
1036
1053
  let conversationSummaries = /* @__PURE__ */ new Map();
1037
1054
  let hasMoreConversations = false;
@@ -1268,7 +1285,7 @@ function createMessagingStore() {
1268
1285
  if (existing === void 0) return;
1269
1286
  if (message.deliveryState === "sending" || message.deletedAt !== null) return;
1270
1287
  if (existing.lastMessageAt !== null && message.createdAt < existing.lastMessageAt) return;
1271
- const shouldCount = args.incrementUnread === true;
1288
+ const shouldCount = args.incrementUnread === true && !isLapsed(message, now());
1272
1289
  replaceOneSummary(message.conversationId, (item) => ({
1273
1290
  ...item,
1274
1291
  lastMessageContent: message.content,
@@ -1331,14 +1348,25 @@ function optimisticMessage(args) {
1331
1348
  action: args.action ?? null,
1332
1349
  attachments: args.attachments ?? [],
1333
1350
  references: args.references ?? [],
1334
- metadata: {}
1351
+ metadata: {},
1352
+ softExpiresAt: args.softExpiresAt ?? null
1335
1353
  };
1336
1354
  }
1337
1355
 
1338
1356
  // src/core/engine.ts
1357
+ var SOFT_EXPIRY_GRACE_MS = 2e3;
1358
+ var MAX_TIMER_DELAY_MS = 2147483647;
1339
1359
  function createMessagingEngine(options) {
1340
1360
  const { repository, manager, identity } = options;
1341
- const store = createMessagingStore();
1361
+ const browserNow = options.now ?? Date.now;
1362
+ let serverClockOffsetMs = 0;
1363
+ const now = () => browserNow() + serverClockOffsetMs;
1364
+ function learnServerClock(serverNow) {
1365
+ if (serverNow === null) return;
1366
+ const server = Date.parse(serverNow);
1367
+ if (Number.isFinite(server)) serverClockOffsetMs = server - browserNow();
1368
+ }
1369
+ const store = createMessagingStore({ now });
1342
1370
  const conversationPageSize = options.conversationPageSize ?? 30;
1343
1371
  const messagePageSize = options.messagePageSize ?? 50;
1344
1372
  const openChannels = /* @__PURE__ */ new Map();
@@ -1352,6 +1380,8 @@ function createMessagingEngine(options) {
1352
1380
  let inboxChannel = null;
1353
1381
  let conversationCursor = null;
1354
1382
  let disposed = false;
1383
+ const pendingExpiry = /* @__PURE__ */ new Map();
1384
+ let expiryTimer = null;
1355
1385
  function report(event) {
1356
1386
  options.onDiagnostic?.(event);
1357
1387
  }
@@ -1394,6 +1424,72 @@ function createMessagingEngine(options) {
1394
1424
  }).catch(() => void 0);
1395
1425
  return current;
1396
1426
  }
1427
+ function scheduleExpiryTimer() {
1428
+ if (expiryTimer !== null) {
1429
+ clearTimeout(expiryTimer);
1430
+ expiryTimer = null;
1431
+ }
1432
+ if (disposed || pendingExpiry.size === 0) return;
1433
+ const soonest = Math.min(...pendingExpiry.values());
1434
+ const delay = Math.min(
1435
+ MAX_TIMER_DELAY_MS,
1436
+ Math.max(0, soonest + SOFT_EXPIRY_GRACE_MS - now())
1437
+ );
1438
+ expiryTimer = setTimeout(() => {
1439
+ expiryTimer = null;
1440
+ void fireSoftExpiry();
1441
+ }, delay);
1442
+ }
1443
+ function noteSoftExpiry(id, at) {
1444
+ if (at === null) return;
1445
+ if (at <= now() && (store.snapshot().conversationSummaries.get(id)?.unreadCount ?? 0) === 0) {
1446
+ return;
1447
+ }
1448
+ const held = pendingExpiry.get(id);
1449
+ if (held !== void 0 && held <= at) return;
1450
+ pendingExpiry.set(id, at);
1451
+ scheduleExpiryTimer();
1452
+ }
1453
+ async function refreshPendingExpiries(ids) {
1454
+ const summaries = store.snapshot().conversationSummaries;
1455
+ const counted = ids.filter((id) => (summaries.get(id)?.unreadCount ?? 0) > 0);
1456
+ ids.forEach((id) => pendingExpiry.delete(id));
1457
+ if (counted.length > 0) {
1458
+ const pending = await repository.pendingSoftExpiries(counted);
1459
+ if (disposed) return;
1460
+ learnServerClock(pending.serverNow);
1461
+ pending.byConversation.forEach(
1462
+ (at, id) => noteSoftExpiry(id, softExpiryTime({ softExpiresAt: at }))
1463
+ );
1464
+ }
1465
+ scheduleExpiryTimer();
1466
+ }
1467
+ async function fireSoftExpiry() {
1468
+ if (disposed) return;
1469
+ const at = now();
1470
+ const due = [...pendingExpiry.entries()].filter(([, expiry]) => expiry <= at).map(([id]) => id);
1471
+ due.forEach((id) => pendingExpiry.delete(id));
1472
+ await Promise.all(
1473
+ due.map(async (id) => {
1474
+ if ((store.snapshot().conversationSummaries.get(id)?.unreadCount ?? 0) === 0) return;
1475
+ try {
1476
+ const count = await repository.unreadCount(id);
1477
+ if (!disposed) store.setUnreadCount(id, count);
1478
+ } catch (error) {
1479
+ reportError(error, "softExpiryUnread", { conversationId: id });
1480
+ }
1481
+ })
1482
+ );
1483
+ if (disposed) return;
1484
+ await refreshPendingExpiries(due).catch(
1485
+ (error) => reportError(error, "softExpiryRefresh")
1486
+ );
1487
+ }
1488
+ function watchSoftExpiries(ids) {
1489
+ void refreshPendingExpiries(ids).catch(
1490
+ (error) => reportError(error, "softExpiryRefresh")
1491
+ );
1492
+ }
1397
1493
  const outbox = createOutbox({
1398
1494
  ...options.outboxStorage !== void 0 ? { storage: options.outboxStorage } : {},
1399
1495
  send: (draft, clientMessageId) => repository.insertMessage(draft, clientMessageId),
@@ -1420,7 +1516,8 @@ function createMessagingEngine(options) {
1420
1516
  ...entry.draft.replyToId !== void 0 ? { replyToId: entry.draft.replyToId } : {},
1421
1517
  ...entry.draft.action !== void 0 ? { action: entry.draft.action } : {},
1422
1518
  ...entry.draft.attachments !== void 0 ? { attachments: [...entry.draft.attachments] } : {},
1423
- ...entry.draft.references !== void 0 ? { references: [...entry.draft.references] } : {}
1519
+ ...entry.draft.references !== void 0 ? { references: [...entry.draft.references] } : {},
1520
+ ...entry.draft.softExpiresAt !== void 0 ? { softExpiresAt: entry.draft.softExpiresAt } : {}
1424
1521
  })
1425
1522
  );
1426
1523
  });
@@ -1449,6 +1546,7 @@ function createMessagingEngine(options) {
1449
1546
  if (disposed) return;
1450
1547
  conversationCursor = page.nextCursor;
1451
1548
  store.setConversations(page.items, page.hasMore);
1549
+ watchSoftExpiries([...store.snapshot().conversationSummaries.keys()]);
1452
1550
  }
1453
1551
  async function reloadArchivedCount() {
1454
1552
  if (disposed) return;
@@ -1643,6 +1741,7 @@ function createMessagingEngine(options) {
1643
1741
  store.ingest(message);
1644
1742
  const isMine = message.senderId === identity.userId;
1645
1743
  store.applyLastMessage(message, { incrementUnread: !isMine });
1744
+ if (!isMine) noteSoftExpiry(message.conversationId, softExpiryTime(message));
1646
1745
  if (!isMine && store.snapshot().activeConversationId === message.conversationId) {
1647
1746
  void persistRead(message.conversationId).catch(() => void 0);
1648
1747
  }
@@ -1704,6 +1803,7 @@ function createMessagingEngine(options) {
1704
1803
  });
1705
1804
  conversationCursor = page.nextCursor;
1706
1805
  store.appendConversations(page.items, page.hasMore);
1806
+ watchSoftExpiries(page.items.map((item) => item.conversation.id));
1707
1807
  } catch (error) {
1708
1808
  reportError(error, "loadMoreConversations");
1709
1809
  }
@@ -1723,7 +1823,10 @@ function createMessagingEngine(options) {
1723
1823
  const directSummary = missingSummary ? await repository.getConversationSummary(id) : null;
1724
1824
  if (!openIsLive(id, generation, ownedChannel)) return;
1725
1825
  store.setConversationReadFailure(id, null);
1726
- if (directSummary !== null) store.setConversationSummary(directSummary);
1826
+ if (directSummary !== null) {
1827
+ store.setConversationSummary(directSummary);
1828
+ watchSoftExpiries([id]);
1829
+ }
1727
1830
  await persistRead(
1728
1831
  id,
1729
1832
  () => openIsLive(id, generation, ownedChannel)
@@ -1818,6 +1921,9 @@ function createMessagingEngine(options) {
1818
1921
  },
1819
1922
  dispose() {
1820
1923
  disposed = true;
1924
+ if (expiryTimer !== null) clearTimeout(expiryTimer);
1925
+ expiryTimer = null;
1926
+ pendingExpiry.clear();
1821
1927
  openChannels.forEach((_handle, id) => {
1822
1928
  initialReadGeneration.set(id, (initialReadGeneration.get(id) ?? 0) + 1);
1823
1929
  });
@@ -2036,10 +2142,13 @@ var RPCS = {
2036
2142
  /** Conversation list + participants + last message + unread, keyset paged. */
2037
2143
  conversationsWithDetails: "get_dm_conversations_with_details",
2038
2144
  unreadCount: "get_dm_unread_count",
2145
+ /** The soonest future soft expiry per conversation, plus the server's clock. */
2146
+ pendingSoftExpiries: "get_dm_pending_soft_expiries",
2039
2147
  userInfo: "get_dm_user_info",
2040
2148
  isParticipant: "is_dm_participant"
2041
2149
  };
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";
2150
+ 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,soft_expires_at";
2151
+ var PENDING_SOFT_EXPIRY_CHUNK = 200;
2043
2152
  var PARTICIPANT_COLUMNS = "conversation_id,user_id,role,joined_at,last_read_at,is_muted,is_archived";
2044
2153
  var CONVERSATION_COLUMNS = "id,type,group_name,group_image_url,created_by,organization_id,created_at,updated_at,metadata";
2045
2154
  function optionalOrg(organizationId) {
@@ -2460,6 +2569,9 @@ function createMessagingRepository(options) {
2460
2569
  // confirmed row instead of showing the message twice.
2461
2570
  client_message_id: clientMessageId,
2462
2571
  action_data: draft.action ?? null,
2572
+ // SOFT EXPIRY: written only when the sender sets one. Lapsed is
2573
+ // derived from it live (`isLapsed`), never stamped.
2574
+ ...draft.softExpiresAt !== void 0 ? { soft_expires_at: draft.softExpiresAt } : {},
2463
2575
  metadata: {
2464
2576
  ...draft.metadata ?? {},
2465
2577
  ...draft.attachments !== void 0 && draft.attachments.length > 0 ? { attachments: draft.attachments } : {},
@@ -2512,6 +2624,56 @@ function createMessagingRepository(options) {
2512
2624
  }
2513
2625
  return Math.floor(unread);
2514
2626
  },
2627
+ async unreadCount(conversationId) {
2628
+ const operation = "unreadCount";
2629
+ const me = await actingUser(operation);
2630
+ const unread = await withSessionRetry(
2631
+ operation,
2632
+ () => rpc(
2633
+ RPCS.unreadCount,
2634
+ { p_conversation_id: conversationId, p_user_id: me },
2635
+ operation
2636
+ )
2637
+ );
2638
+ if (typeof unread !== "number" || !Number.isFinite(unread) || unread < 0) {
2639
+ throw invalidResponse(operation, `${RPCS.unreadCount} returned ${JSON.stringify(unread)}`);
2640
+ }
2641
+ return Math.floor(unread);
2642
+ },
2643
+ async pendingSoftExpiries(conversationIds) {
2644
+ const operation = "pendingSoftExpiries";
2645
+ const unique = [...new Set(conversationIds)];
2646
+ const byConversation = /* @__PURE__ */ new Map();
2647
+ let serverNow = null;
2648
+ for (let start = 0; start < unique.length; start += PENDING_SOFT_EXPIRY_CHUNK) {
2649
+ const chunk = unique.slice(start, start + PENDING_SOFT_EXPIRY_CHUNK);
2650
+ const answer = await withSessionRetry(
2651
+ operation,
2652
+ () => rpc(
2653
+ RPCS.pendingSoftExpiries,
2654
+ { p_conversation_ids: chunk },
2655
+ operation
2656
+ )
2657
+ );
2658
+ const now = answer?.["server_now"];
2659
+ const pending = answer?.["pending"];
2660
+ if (typeof now !== "string" || !Array.isArray(pending)) {
2661
+ throw invalidResponse(
2662
+ operation,
2663
+ `${RPCS.pendingSoftExpiries} returned ${JSON.stringify(answer)}`
2664
+ );
2665
+ }
2666
+ serverNow = now;
2667
+ for (const row of pending) {
2668
+ const id = row["conversation_id"];
2669
+ const at = row["soft_expires_at"];
2670
+ if (typeof id === "string" && typeof at === "string") {
2671
+ byConversation.set(id, at);
2672
+ }
2673
+ }
2674
+ }
2675
+ return { byConversation, serverNow };
2676
+ },
2515
2677
  async setConversationFlags(conversationId, flags) {
2516
2678
  const operation = "setConversationFlags";
2517
2679
  const me = await actingUser(operation);
@@ -3918,6 +4080,23 @@ function ConversationRow(props) {
3918
4080
  }
3919
4081
  );
3920
4082
  }
4083
+ function useLapseClock(messages) {
4084
+ const [now, setNow] = (0, import_react5.useState)(() => Date.now());
4085
+ let soonest = null;
4086
+ for (const message of messages) {
4087
+ const at = softExpiryTime(message);
4088
+ if (at !== null && at > now && (soonest === null || at < soonest)) soonest = at;
4089
+ }
4090
+ (0, import_react5.useEffect)(() => {
4091
+ if (soonest === null) return;
4092
+ const timer = setTimeout(
4093
+ () => setNow(Date.now()),
4094
+ Math.min(2147483647, Math.max(0, soonest - Date.now()))
4095
+ );
4096
+ return () => clearTimeout(timer);
4097
+ }, [soonest]);
4098
+ return now;
4099
+ }
3921
4100
  function ConversationView(props) {
3922
4101
  const { conversationId } = props;
3923
4102
  const host = useMessagingHost();
@@ -3930,6 +4109,7 @@ function ConversationView(props) {
3930
4109
  const selfId = host?.identity.userId ?? "";
3931
4110
  const messages = conversation.messages;
3932
4111
  const lastId = messages.at(-1)?.id ?? null;
4112
+ const lapseNow = useLapseClock(messages);
3933
4113
  (0, import_react5.useEffect)(() => {
3934
4114
  const node = scrollRef.current;
3935
4115
  if (node === null) return;
@@ -4061,6 +4241,7 @@ function ConversationView(props) {
4061
4241
  MessageGroupView,
4062
4242
  {
4063
4243
  allMessages: messages,
4244
+ now: lapseNow,
4064
4245
  showAuthor: conversation.summary?.conversation.type !== "direct",
4065
4246
  messages: group.messages,
4066
4247
  actor,
@@ -4121,6 +4302,7 @@ function MessageGroupView(props) {
4121
4302
  replyTo: props.allMessages.find((parent) => parent.id === message.replyToId),
4122
4303
  isMine: props.isMine,
4123
4304
  isLast: index === props.messages.length - 1,
4305
+ now: props.now,
4124
4306
  onRetry: props.onRetry,
4125
4307
  onReply: props.onReply,
4126
4308
  failedEntryId: message.clientMessageId !== null ? props.failedEntryIdByClientId.get(message.clientMessageId) ?? null : null
@@ -4140,12 +4322,15 @@ function MessageBubble(props) {
4140
4322
  if (message.deletedAt !== null) {
4141
4323
  return /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("div", { className: "mx-msg__bubble mx-msg__bubble--deleted", children: "Message deleted" });
4142
4324
  }
4325
+ const lapsed = isLapsed(message, props.now ?? Date.now());
4143
4326
  const classes = [
4144
4327
  "mx-msg__bubble",
4145
4328
  isMine ? "mx-msg__bubble--mine" : "",
4146
4329
  props.isLast ? "mx-msg__bubble--tail" : "",
4147
4330
  message.deliveryState === "sending" ? "mx-msg__bubble--pending" : "",
4148
- message.deliveryState === "failed" ? "mx-msg__bubble--failed" : ""
4331
+ message.deliveryState === "failed" ? "mx-msg__bubble--failed" : "",
4332
+ // SOFT EXPIRY: past, still fully readable and actionable — muted, never hidden.
4333
+ lapsed ? "mx-msg__bubble--lapsed" : ""
4149
4334
  ].filter(Boolean).join(" ");
4150
4335
  const bubble = /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)(import_jsx_runtime4.Fragment, { children: [
4151
4336
  /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)("div", { className: classes, title: formatMessageTime(message.createdAt), children: [
@@ -4195,6 +4380,17 @@ function MessageBubble(props) {
4195
4380
  children: [
4196
4381
  /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("span", { className: "mx-msg__message-time", children: props.isLast ? formatMessageTime(message.createdAt) : null }),
4197
4382
  message.editedAt !== null ? " \xB7 edited" : null,
4383
+ lapsed && message.softExpiresAt !== null ? /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)(
4384
+ "span",
4385
+ {
4386
+ className: "mx-msg__lapsed",
4387
+ title: `Past ${formatMessageTime(message.softExpiresAt)}`,
4388
+ children: [
4389
+ /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(ClockIcon, {}),
4390
+ "Past"
4391
+ ]
4392
+ }
4393
+ ) : null,
4198
4394
  isMine ? /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(DeliveryTick, { state: message.deliveryState }) : null,
4199
4395
  message.deliveryState === "failed" && props.failedEntryId != null && props.onRetry !== void 0 ? /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(
4200
4396
  "button",