@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.js CHANGED
@@ -802,7 +802,8 @@ function projectBroadcastMessage(value) {
802
802
  action: projectMessageAction(record["action"]),
803
803
  attachments: projectAttachmentList(record["attachments"]),
804
804
  references: projectReferenceList(record["references"]),
805
- metadata
805
+ metadata,
806
+ softExpiresAt: str(record, "softExpiresAt")
806
807
  };
807
808
  }
808
809
  function projectMessage(row, fallbackOrganizationId) {
@@ -832,7 +833,8 @@ function projectMessage(row, fallbackOrganizationId) {
832
833
  action,
833
834
  attachments: projectAttachments(metadata),
834
835
  references: projectReferences(metadata),
835
- metadata
836
+ metadata,
837
+ softExpiresAt: str(row, "soft_expires_at")
836
838
  };
837
839
  }
838
840
  function projectParticipantRole(value) {
@@ -885,6 +887,18 @@ function projectConversationSummary(row, viewerId, fallbackOrganizationId) {
885
887
  };
886
888
  }
887
889
 
890
+ // src/core/lapse.ts
891
+ function softExpiryTime(message) {
892
+ if (message.softExpiresAt === null) return null;
893
+ const parsed = Date.parse(message.softExpiresAt);
894
+ return Number.isFinite(parsed) ? parsed : null;
895
+ }
896
+ function isLapsed(message, now = Date.now()) {
897
+ const at = softExpiryTime(message);
898
+ if (at === null) return false;
899
+ return at <= (typeof now === "number" ? now : now.getTime());
900
+ }
901
+
888
902
  // src/core/types.ts
889
903
  var asConversationId = (value) => value;
890
904
  var asMessageId = (value) => value;
@@ -922,7 +936,8 @@ function insertOrdered(messages, message) {
922
936
  next.splice(index, 0, message);
923
937
  return next;
924
938
  }
925
- function createMessagingStore() {
939
+ function createMessagingStore(options = {}) {
940
+ const now = options.now ?? Date.now;
926
941
  let conversations = [];
927
942
  let conversationSummaries = /* @__PURE__ */ new Map();
928
943
  let hasMoreConversations = false;
@@ -1159,7 +1174,7 @@ function createMessagingStore() {
1159
1174
  if (existing === void 0) return;
1160
1175
  if (message.deliveryState === "sending" || message.deletedAt !== null) return;
1161
1176
  if (existing.lastMessageAt !== null && message.createdAt < existing.lastMessageAt) return;
1162
- const shouldCount = args.incrementUnread === true;
1177
+ const shouldCount = args.incrementUnread === true && !isLapsed(message, now());
1163
1178
  replaceOneSummary(message.conversationId, (item) => ({
1164
1179
  ...item,
1165
1180
  lastMessageContent: message.content,
@@ -1222,14 +1237,25 @@ function optimisticMessage(args) {
1222
1237
  action: args.action ?? null,
1223
1238
  attachments: args.attachments ?? [],
1224
1239
  references: args.references ?? [],
1225
- metadata: {}
1240
+ metadata: {},
1241
+ softExpiresAt: args.softExpiresAt ?? null
1226
1242
  };
1227
1243
  }
1228
1244
 
1229
1245
  // src/core/engine.ts
1246
+ var SOFT_EXPIRY_GRACE_MS = 2e3;
1247
+ var MAX_TIMER_DELAY_MS = 2147483647;
1230
1248
  function createMessagingEngine(options) {
1231
1249
  const { repository, manager, identity } = options;
1232
- const store = createMessagingStore();
1250
+ const browserNow = options.now ?? Date.now;
1251
+ let serverClockOffsetMs = 0;
1252
+ const now = () => browserNow() + serverClockOffsetMs;
1253
+ function learnServerClock(serverNow) {
1254
+ if (serverNow === null) return;
1255
+ const server = Date.parse(serverNow);
1256
+ if (Number.isFinite(server)) serverClockOffsetMs = server - browserNow();
1257
+ }
1258
+ const store = createMessagingStore({ now });
1233
1259
  const conversationPageSize = options.conversationPageSize ?? 30;
1234
1260
  const messagePageSize = options.messagePageSize ?? 50;
1235
1261
  const openChannels = /* @__PURE__ */ new Map();
@@ -1243,6 +1269,8 @@ function createMessagingEngine(options) {
1243
1269
  let inboxChannel = null;
1244
1270
  let conversationCursor = null;
1245
1271
  let disposed = false;
1272
+ const pendingExpiry = /* @__PURE__ */ new Map();
1273
+ let expiryTimer = null;
1246
1274
  function report(event) {
1247
1275
  options.onDiagnostic?.(event);
1248
1276
  }
@@ -1285,6 +1313,72 @@ function createMessagingEngine(options) {
1285
1313
  }).catch(() => void 0);
1286
1314
  return current;
1287
1315
  }
1316
+ function scheduleExpiryTimer() {
1317
+ if (expiryTimer !== null) {
1318
+ clearTimeout(expiryTimer);
1319
+ expiryTimer = null;
1320
+ }
1321
+ if (disposed || pendingExpiry.size === 0) return;
1322
+ const soonest = Math.min(...pendingExpiry.values());
1323
+ const delay = Math.min(
1324
+ MAX_TIMER_DELAY_MS,
1325
+ Math.max(0, soonest + SOFT_EXPIRY_GRACE_MS - now())
1326
+ );
1327
+ expiryTimer = setTimeout(() => {
1328
+ expiryTimer = null;
1329
+ void fireSoftExpiry();
1330
+ }, delay);
1331
+ }
1332
+ function noteSoftExpiry(id, at) {
1333
+ if (at === null) return;
1334
+ if (at <= now() && (store.snapshot().conversationSummaries.get(id)?.unreadCount ?? 0) === 0) {
1335
+ return;
1336
+ }
1337
+ const held = pendingExpiry.get(id);
1338
+ if (held !== void 0 && held <= at) return;
1339
+ pendingExpiry.set(id, at);
1340
+ scheduleExpiryTimer();
1341
+ }
1342
+ async function refreshPendingExpiries(ids) {
1343
+ const summaries = store.snapshot().conversationSummaries;
1344
+ const counted = ids.filter((id) => (summaries.get(id)?.unreadCount ?? 0) > 0);
1345
+ ids.forEach((id) => pendingExpiry.delete(id));
1346
+ if (counted.length > 0) {
1347
+ const pending = await repository.pendingSoftExpiries(counted);
1348
+ if (disposed) return;
1349
+ learnServerClock(pending.serverNow);
1350
+ pending.byConversation.forEach(
1351
+ (at, id) => noteSoftExpiry(id, softExpiryTime({ softExpiresAt: at }))
1352
+ );
1353
+ }
1354
+ scheduleExpiryTimer();
1355
+ }
1356
+ async function fireSoftExpiry() {
1357
+ if (disposed) return;
1358
+ const at = now();
1359
+ const due = [...pendingExpiry.entries()].filter(([, expiry]) => expiry <= at).map(([id]) => id);
1360
+ due.forEach((id) => pendingExpiry.delete(id));
1361
+ await Promise.all(
1362
+ due.map(async (id) => {
1363
+ if ((store.snapshot().conversationSummaries.get(id)?.unreadCount ?? 0) === 0) return;
1364
+ try {
1365
+ const count = await repository.unreadCount(id);
1366
+ if (!disposed) store.setUnreadCount(id, count);
1367
+ } catch (error) {
1368
+ reportError(error, "softExpiryUnread", { conversationId: id });
1369
+ }
1370
+ })
1371
+ );
1372
+ if (disposed) return;
1373
+ await refreshPendingExpiries(due).catch(
1374
+ (error) => reportError(error, "softExpiryRefresh")
1375
+ );
1376
+ }
1377
+ function watchSoftExpiries(ids) {
1378
+ void refreshPendingExpiries(ids).catch(
1379
+ (error) => reportError(error, "softExpiryRefresh")
1380
+ );
1381
+ }
1288
1382
  const outbox = createOutbox({
1289
1383
  ...options.outboxStorage !== void 0 ? { storage: options.outboxStorage } : {},
1290
1384
  send: (draft, clientMessageId) => repository.insertMessage(draft, clientMessageId),
@@ -1311,7 +1405,8 @@ function createMessagingEngine(options) {
1311
1405
  ...entry.draft.replyToId !== void 0 ? { replyToId: entry.draft.replyToId } : {},
1312
1406
  ...entry.draft.action !== void 0 ? { action: entry.draft.action } : {},
1313
1407
  ...entry.draft.attachments !== void 0 ? { attachments: [...entry.draft.attachments] } : {},
1314
- ...entry.draft.references !== void 0 ? { references: [...entry.draft.references] } : {}
1408
+ ...entry.draft.references !== void 0 ? { references: [...entry.draft.references] } : {},
1409
+ ...entry.draft.softExpiresAt !== void 0 ? { softExpiresAt: entry.draft.softExpiresAt } : {}
1315
1410
  })
1316
1411
  );
1317
1412
  });
@@ -1340,6 +1435,7 @@ function createMessagingEngine(options) {
1340
1435
  if (disposed) return;
1341
1436
  conversationCursor = page.nextCursor;
1342
1437
  store.setConversations(page.items, page.hasMore);
1438
+ watchSoftExpiries([...store.snapshot().conversationSummaries.keys()]);
1343
1439
  }
1344
1440
  async function reloadArchivedCount() {
1345
1441
  if (disposed) return;
@@ -1534,6 +1630,7 @@ function createMessagingEngine(options) {
1534
1630
  store.ingest(message);
1535
1631
  const isMine = message.senderId === identity.userId;
1536
1632
  store.applyLastMessage(message, { incrementUnread: !isMine });
1633
+ if (!isMine) noteSoftExpiry(message.conversationId, softExpiryTime(message));
1537
1634
  if (!isMine && store.snapshot().activeConversationId === message.conversationId) {
1538
1635
  void persistRead(message.conversationId).catch(() => void 0);
1539
1636
  }
@@ -1595,6 +1692,7 @@ function createMessagingEngine(options) {
1595
1692
  });
1596
1693
  conversationCursor = page.nextCursor;
1597
1694
  store.appendConversations(page.items, page.hasMore);
1695
+ watchSoftExpiries(page.items.map((item) => item.conversation.id));
1598
1696
  } catch (error) {
1599
1697
  reportError(error, "loadMoreConversations");
1600
1698
  }
@@ -1614,7 +1712,10 @@ function createMessagingEngine(options) {
1614
1712
  const directSummary = missingSummary ? await repository.getConversationSummary(id) : null;
1615
1713
  if (!openIsLive(id, generation, ownedChannel)) return;
1616
1714
  store.setConversationReadFailure(id, null);
1617
- if (directSummary !== null) store.setConversationSummary(directSummary);
1715
+ if (directSummary !== null) {
1716
+ store.setConversationSummary(directSummary);
1717
+ watchSoftExpiries([id]);
1718
+ }
1618
1719
  await persistRead(
1619
1720
  id,
1620
1721
  () => openIsLive(id, generation, ownedChannel)
@@ -1709,6 +1810,9 @@ function createMessagingEngine(options) {
1709
1810
  },
1710
1811
  dispose() {
1711
1812
  disposed = true;
1813
+ if (expiryTimer !== null) clearTimeout(expiryTimer);
1814
+ expiryTimer = null;
1815
+ pendingExpiry.clear();
1712
1816
  openChannels.forEach((_handle, id) => {
1713
1817
  initialReadGeneration.set(id, (initialReadGeneration.get(id) ?? 0) + 1);
1714
1818
  });
@@ -1927,10 +2031,13 @@ var RPCS = {
1927
2031
  /** Conversation list + participants + last message + unread, keyset paged. */
1928
2032
  conversationsWithDetails: "get_dm_conversations_with_details",
1929
2033
  unreadCount: "get_dm_unread_count",
2034
+ /** The soonest future soft expiry per conversation, plus the server's clock. */
2035
+ pendingSoftExpiries: "get_dm_pending_soft_expiries",
1930
2036
  userInfo: "get_dm_user_info",
1931
2037
  isParticipant: "is_dm_participant"
1932
2038
  };
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";
2039
+ 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";
2040
+ var PENDING_SOFT_EXPIRY_CHUNK = 200;
1934
2041
  var PARTICIPANT_COLUMNS = "conversation_id,user_id,role,joined_at,last_read_at,is_muted,is_archived";
1935
2042
  var CONVERSATION_COLUMNS = "id,type,group_name,group_image_url,created_by,organization_id,created_at,updated_at,metadata";
1936
2043
  function optionalOrg(organizationId) {
@@ -2351,6 +2458,9 @@ function createMessagingRepository(options) {
2351
2458
  // confirmed row instead of showing the message twice.
2352
2459
  client_message_id: clientMessageId,
2353
2460
  action_data: draft.action ?? null,
2461
+ // SOFT EXPIRY: written only when the sender sets one. Lapsed is
2462
+ // derived from it live (`isLapsed`), never stamped.
2463
+ ...draft.softExpiresAt !== void 0 ? { soft_expires_at: draft.softExpiresAt } : {},
2354
2464
  metadata: {
2355
2465
  ...draft.metadata ?? {},
2356
2466
  ...draft.attachments !== void 0 && draft.attachments.length > 0 ? { attachments: draft.attachments } : {},
@@ -2403,6 +2513,56 @@ function createMessagingRepository(options) {
2403
2513
  }
2404
2514
  return Math.floor(unread);
2405
2515
  },
2516
+ async unreadCount(conversationId) {
2517
+ const operation = "unreadCount";
2518
+ const me = await actingUser(operation);
2519
+ const unread = await withSessionRetry(
2520
+ operation,
2521
+ () => rpc(
2522
+ RPCS.unreadCount,
2523
+ { p_conversation_id: conversationId, p_user_id: me },
2524
+ operation
2525
+ )
2526
+ );
2527
+ if (typeof unread !== "number" || !Number.isFinite(unread) || unread < 0) {
2528
+ throw invalidResponse(operation, `${RPCS.unreadCount} returned ${JSON.stringify(unread)}`);
2529
+ }
2530
+ return Math.floor(unread);
2531
+ },
2532
+ async pendingSoftExpiries(conversationIds) {
2533
+ const operation = "pendingSoftExpiries";
2534
+ const unique = [...new Set(conversationIds)];
2535
+ const byConversation = /* @__PURE__ */ new Map();
2536
+ let serverNow = null;
2537
+ for (let start = 0; start < unique.length; start += PENDING_SOFT_EXPIRY_CHUNK) {
2538
+ const chunk = unique.slice(start, start + PENDING_SOFT_EXPIRY_CHUNK);
2539
+ const answer = await withSessionRetry(
2540
+ operation,
2541
+ () => rpc(
2542
+ RPCS.pendingSoftExpiries,
2543
+ { p_conversation_ids: chunk },
2544
+ operation
2545
+ )
2546
+ );
2547
+ const now = answer?.["server_now"];
2548
+ const pending = answer?.["pending"];
2549
+ if (typeof now !== "string" || !Array.isArray(pending)) {
2550
+ throw invalidResponse(
2551
+ operation,
2552
+ `${RPCS.pendingSoftExpiries} returned ${JSON.stringify(answer)}`
2553
+ );
2554
+ }
2555
+ serverNow = now;
2556
+ for (const row of pending) {
2557
+ const id = row["conversation_id"];
2558
+ const at = row["soft_expires_at"];
2559
+ if (typeof id === "string" && typeof at === "string") {
2560
+ byConversation.set(id, at);
2561
+ }
2562
+ }
2563
+ }
2564
+ return { byConversation, serverNow };
2565
+ },
2406
2566
  async setConversationFlags(conversationId, flags) {
2407
2567
  const operation = "setConversationFlags";
2408
2568
  const me = await actingUser(operation);
@@ -3814,6 +3974,23 @@ function ConversationRow(props) {
3814
3974
  }
3815
3975
  );
3816
3976
  }
3977
+ function useLapseClock(messages) {
3978
+ const [now, setNow] = useState2(() => Date.now());
3979
+ let soonest = null;
3980
+ for (const message of messages) {
3981
+ const at = softExpiryTime(message);
3982
+ if (at !== null && at > now && (soonest === null || at < soonest)) soonest = at;
3983
+ }
3984
+ useEffect3(() => {
3985
+ if (soonest === null) return;
3986
+ const timer = setTimeout(
3987
+ () => setNow(Date.now()),
3988
+ Math.min(2147483647, Math.max(0, soonest - Date.now()))
3989
+ );
3990
+ return () => clearTimeout(timer);
3991
+ }, [soonest]);
3992
+ return now;
3993
+ }
3817
3994
  function ConversationView(props) {
3818
3995
  const { conversationId } = props;
3819
3996
  const host = useMessagingHost();
@@ -3826,6 +4003,7 @@ function ConversationView(props) {
3826
4003
  const selfId = host?.identity.userId ?? "";
3827
4004
  const messages = conversation.messages;
3828
4005
  const lastId = messages.at(-1)?.id ?? null;
4006
+ const lapseNow = useLapseClock(messages);
3829
4007
  useEffect3(() => {
3830
4008
  const node = scrollRef.current;
3831
4009
  if (node === null) return;
@@ -3957,6 +4135,7 @@ function ConversationView(props) {
3957
4135
  MessageGroupView,
3958
4136
  {
3959
4137
  allMessages: messages,
4138
+ now: lapseNow,
3960
4139
  showAuthor: conversation.summary?.conversation.type !== "direct",
3961
4140
  messages: group.messages,
3962
4141
  actor,
@@ -4017,6 +4196,7 @@ function MessageGroupView(props) {
4017
4196
  replyTo: props.allMessages.find((parent) => parent.id === message.replyToId),
4018
4197
  isMine: props.isMine,
4019
4198
  isLast: index === props.messages.length - 1,
4199
+ now: props.now,
4020
4200
  onRetry: props.onRetry,
4021
4201
  onReply: props.onReply,
4022
4202
  failedEntryId: message.clientMessageId !== null ? props.failedEntryIdByClientId.get(message.clientMessageId) ?? null : null
@@ -4036,12 +4216,15 @@ function MessageBubble(props) {
4036
4216
  if (message.deletedAt !== null) {
4037
4217
  return /* @__PURE__ */ jsx4("div", { className: "mx-msg__bubble mx-msg__bubble--deleted", children: "Message deleted" });
4038
4218
  }
4219
+ const lapsed = isLapsed(message, props.now ?? Date.now());
4039
4220
  const classes = [
4040
4221
  "mx-msg__bubble",
4041
4222
  isMine ? "mx-msg__bubble--mine" : "",
4042
4223
  props.isLast ? "mx-msg__bubble--tail" : "",
4043
4224
  message.deliveryState === "sending" ? "mx-msg__bubble--pending" : "",
4044
- message.deliveryState === "failed" ? "mx-msg__bubble--failed" : ""
4225
+ message.deliveryState === "failed" ? "mx-msg__bubble--failed" : "",
4226
+ // SOFT EXPIRY: past, still fully readable and actionable — muted, never hidden.
4227
+ lapsed ? "mx-msg__bubble--lapsed" : ""
4045
4228
  ].filter(Boolean).join(" ");
4046
4229
  const bubble = /* @__PURE__ */ jsxs2(Fragment, { children: [
4047
4230
  /* @__PURE__ */ jsxs2("div", { className: classes, title: formatMessageTime(message.createdAt), children: [
@@ -4091,6 +4274,17 @@ function MessageBubble(props) {
4091
4274
  children: [
4092
4275
  /* @__PURE__ */ jsx4("span", { className: "mx-msg__message-time", children: props.isLast ? formatMessageTime(message.createdAt) : null }),
4093
4276
  message.editedAt !== null ? " \xB7 edited" : null,
4277
+ lapsed && message.softExpiresAt !== null ? /* @__PURE__ */ jsxs2(
4278
+ "span",
4279
+ {
4280
+ className: "mx-msg__lapsed",
4281
+ title: `Past ${formatMessageTime(message.softExpiresAt)}`,
4282
+ children: [
4283
+ /* @__PURE__ */ jsx4(ClockIcon, {}),
4284
+ "Past"
4285
+ ]
4286
+ }
4287
+ ) : null,
4094
4288
  isMine ? /* @__PURE__ */ jsx4(DeliveryTick, { state: message.deliveryState }) : null,
4095
4289
  message.deliveryState === "failed" && props.failedEntryId != null && props.onRetry !== void 0 ? /* @__PURE__ */ jsx4(
4096
4290
  "button",
@@ -4367,6 +4561,7 @@ export {
4367
4561
  groupMessages,
4368
4562
  inboxTopic,
4369
4563
  invalidResponse,
4564
+ isLapsed,
4370
4565
  isSameDay,
4371
4566
  messagingClientId,
4372
4567
  normalizeMessagingError,
@@ -4378,6 +4573,7 @@ export {
4378
4573
  projectParticipantRole,
4379
4574
  projectUserSummary,
4380
4575
  resolveActor,
4576
+ softExpiryTime,
4381
4577
  splitText,
4382
4578
  summarizeText,
4383
4579
  toMessagingArchiveFilter,