@ai-matrx/messaging 0.15.82 → 0.16.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,12 +1,63 @@
1
1
  # Changelog — `@ai-matrx/messaging`
2
2
 
3
+ ## 0.16.1
4
+
5
+ Automatic changed-only republish (docs/metadata drift since the last tag — see
6
+ `git diff npm/messaging/v0.16.0..npm/messaging/v0.16.1 -- apps/shared/messaging`).
7
+ No source changes intended and no consumer action required.
8
+
9
+ ## 0.16.0
10
+
11
+ **DM soft expiry.** A direct message can carry `softExpiresAt` (column
12
+ `communication.dm_messages.soft_expires_at`). Past that instant the message is
13
+ LAPSED: still readable and actionable, never hidden or edited, and it no longer
14
+ counts as unread. The reader's `last_read_at` watermark is never moved for it.
15
+ Lapsed is derived live, never stamped — no sweeper, no schedule.
16
+
17
+ - `Message.softExpiresAt: string | null` (required) and
18
+ `DraftMessage.softExpiresAt?: string`; every message read selects
19
+ `soft_expires_at`, and `insertMessage` writes it only when a draft sets it.
20
+ - `isLapsed(message, now)` — the ONE lapse rule — and `softExpiryTime(message)`,
21
+ both exported from the core entry.
22
+ - Store: an incoming message that has already lapsed never increments
23
+ `unreadCount` (store rule 6) — judged on the SERVER's clock (below).
24
+ - Engine: ONE timer for the soonest pending expiry across the loaded inbox. When
25
+ it fires, the due conversations' counts are RE-READ from the server
26
+ (`get_dm_unread_count` already excludes lapsed messages), so
27
+ `totalUnreadConversations` and every badge follow without a reload. Lapse is
28
+ judged on the server's clock: each `pendingSoftExpiries` answer carries the
29
+ database's `now()` and the engine keeps the offset, so a browser clock that runs
30
+ fast never hides a message the server still counts; an expiry already past
31
+ re-reads the server's count at once instead of subtracting locally.
32
+ - Repository: `unreadCount(conversationId)` (read only) and
33
+ `pendingSoftExpiries(conversationIds)` → `{ byConversation, serverNow }`, through
34
+ `public.get_dm_pending_soft_expiries` — ONE answer per conversation (its soonest
35
+ pending expiry), chunked 200 conversations per call, so a busy thread can never
36
+ starve its neighbours out of a row cap.
37
+ - `MessageBubble`: a lapsed message renders muted and outlined with a small
38
+ clock + "Past" marker in its meta row; its chips and links keep working.
39
+ `ConversationView` drives it from one clock per thread.
40
+
41
+ **Requires the database.** Every message read now names `soft_expires_at`, and
42
+ `pendingSoftExpiries` calls `public.get_dm_pending_soft_expiries`; against a
43
+ database without them DM reads fail. matrx-frontend
44
+ `migrations/dm_soft_expiry_and_dm_pairs_with_email.sql` is on production since 2026-10-04.
45
+
46
+ ### Consumer action
47
+
48
+ - Any code that constructs a `Message` literal (fixtures, demos, optimistic
49
+ stand-ins) adds `softExpiresAt: null`.
50
+ - A sender that wants a soft expiry passes `softExpiresAt` (ISO instant) on the
51
+ draft — never compare the field directly; ask `isLapsed`.
52
+ - A custom repository implementing `MessagingRepository` adds `unreadCount` and
53
+ `pendingSoftExpiries`.
54
+
3
55
  ## 0.15.82
4
56
  ### The unread badge is a content-sized pill again
5
57
 
6
58
  - `.mx-msg__row-preview span` also matched the unread badge (a span in the same row) and stretched a "1" to a ~100px bar; the selector is now `.mx-msg__row-preview > span:first-child`, the preview text only.
7
59
  - Consumer action: delete any host override pinning `.mx-msg__row-preview > .mx-msg__badge` to `flex: 0 0 auto`.
8
60
 
9
-
10
61
  ## 0.15.81
11
62
 
12
63
  Automatic changed-only republish (docs/metadata drift since the last tag — see
package/dist/index.cjs CHANGED
@@ -56,6 +56,7 @@ __export(src_exports, {
56
56
  groupMessages: () => groupMessages,
57
57
  inboxTopic: () => inboxTopic,
58
58
  invalidResponse: () => invalidResponse,
59
+ isLapsed: () => isLapsed,
59
60
  isSameDay: () => isSameDay,
60
61
  messagingClientId: () => messagingClientId,
61
62
  normalizeMessagingError: () => normalizeMessagingError,
@@ -67,6 +68,7 @@ __export(src_exports, {
67
68
  projectParticipantRole: () => projectParticipantRole,
68
69
  projectUserSummary: () => projectUserSummary,
69
70
  resolveActor: () => resolveActor,
71
+ softExpiryTime: () => softExpiryTime,
70
72
  splitText: () => splitText,
71
73
  summarizeText: () => summarizeText,
72
74
  toMessagingArchiveFilter: () => toMessagingArchiveFilter,
@@ -973,7 +975,8 @@ function projectBroadcastMessage(value) {
973
975
  action: projectMessageAction(record["action"]),
974
976
  attachments: projectAttachmentList(record["attachments"]),
975
977
  references: projectReferenceList(record["references"]),
976
- metadata
978
+ metadata,
979
+ softExpiresAt: str(record, "softExpiresAt")
977
980
  };
978
981
  }
979
982
  function projectMessage(row, fallbackOrganizationId) {
@@ -1003,7 +1006,8 @@ function projectMessage(row, fallbackOrganizationId) {
1003
1006
  action,
1004
1007
  attachments: projectAttachments(metadata),
1005
1008
  references: projectReferences(metadata),
1006
- metadata
1009
+ metadata,
1010
+ softExpiresAt: str(row, "soft_expires_at")
1007
1011
  };
1008
1012
  }
1009
1013
  function projectParticipantRole(value) {
@@ -1056,6 +1060,18 @@ function projectConversationSummary(row, viewerId, fallbackOrganizationId) {
1056
1060
  };
1057
1061
  }
1058
1062
 
1063
+ // src/core/lapse.ts
1064
+ function softExpiryTime(message) {
1065
+ if (message.softExpiresAt === null) return null;
1066
+ const parsed = Date.parse(message.softExpiresAt);
1067
+ return Number.isFinite(parsed) ? parsed : null;
1068
+ }
1069
+ function isLapsed(message, now = Date.now()) {
1070
+ const at = softExpiryTime(message);
1071
+ if (at === null) return false;
1072
+ return at <= (typeof now === "number" ? now : now.getTime());
1073
+ }
1074
+
1059
1075
  // src/core/types.ts
1060
1076
  var asConversationId = (value) => value;
1061
1077
  var asMessageId = (value) => value;
@@ -1093,7 +1109,8 @@ function insertOrdered(messages, message) {
1093
1109
  next.splice(index, 0, message);
1094
1110
  return next;
1095
1111
  }
1096
- function createMessagingStore() {
1112
+ function createMessagingStore(options = {}) {
1113
+ const now = options.now ?? Date.now;
1097
1114
  let conversations = [];
1098
1115
  let conversationSummaries = /* @__PURE__ */ new Map();
1099
1116
  let hasMoreConversations = false;
@@ -1330,7 +1347,7 @@ function createMessagingStore() {
1330
1347
  if (existing === void 0) return;
1331
1348
  if (message.deliveryState === "sending" || message.deletedAt !== null) return;
1332
1349
  if (existing.lastMessageAt !== null && message.createdAt < existing.lastMessageAt) return;
1333
- const shouldCount = args.incrementUnread === true;
1350
+ const shouldCount = args.incrementUnread === true && !isLapsed(message, now());
1334
1351
  replaceOneSummary(message.conversationId, (item) => ({
1335
1352
  ...item,
1336
1353
  lastMessageContent: message.content,
@@ -1393,14 +1410,25 @@ function optimisticMessage(args) {
1393
1410
  action: args.action ?? null,
1394
1411
  attachments: args.attachments ?? [],
1395
1412
  references: args.references ?? [],
1396
- metadata: {}
1413
+ metadata: {},
1414
+ softExpiresAt: args.softExpiresAt ?? null
1397
1415
  };
1398
1416
  }
1399
1417
 
1400
1418
  // src/core/engine.ts
1419
+ var SOFT_EXPIRY_GRACE_MS = 2e3;
1420
+ var MAX_TIMER_DELAY_MS = 2147483647;
1401
1421
  function createMessagingEngine(options) {
1402
1422
  const { repository, manager, identity } = options;
1403
- const store = createMessagingStore();
1423
+ const browserNow = options.now ?? Date.now;
1424
+ let serverClockOffsetMs = 0;
1425
+ const now = () => browserNow() + serverClockOffsetMs;
1426
+ function learnServerClock(serverNow) {
1427
+ if (serverNow === null) return;
1428
+ const server = Date.parse(serverNow);
1429
+ if (Number.isFinite(server)) serverClockOffsetMs = server - browserNow();
1430
+ }
1431
+ const store = createMessagingStore({ now });
1404
1432
  const conversationPageSize = options.conversationPageSize ?? 30;
1405
1433
  const messagePageSize = options.messagePageSize ?? 50;
1406
1434
  const openChannels = /* @__PURE__ */ new Map();
@@ -1414,6 +1442,8 @@ function createMessagingEngine(options) {
1414
1442
  let inboxChannel = null;
1415
1443
  let conversationCursor = null;
1416
1444
  let disposed = false;
1445
+ const pendingExpiry = /* @__PURE__ */ new Map();
1446
+ let expiryTimer = null;
1417
1447
  function report(event) {
1418
1448
  options.onDiagnostic?.(event);
1419
1449
  }
@@ -1456,6 +1486,72 @@ function createMessagingEngine(options) {
1456
1486
  }).catch(() => void 0);
1457
1487
  return current;
1458
1488
  }
1489
+ function scheduleExpiryTimer() {
1490
+ if (expiryTimer !== null) {
1491
+ clearTimeout(expiryTimer);
1492
+ expiryTimer = null;
1493
+ }
1494
+ if (disposed || pendingExpiry.size === 0) return;
1495
+ const soonest = Math.min(...pendingExpiry.values());
1496
+ const delay = Math.min(
1497
+ MAX_TIMER_DELAY_MS,
1498
+ Math.max(0, soonest + SOFT_EXPIRY_GRACE_MS - now())
1499
+ );
1500
+ expiryTimer = setTimeout(() => {
1501
+ expiryTimer = null;
1502
+ void fireSoftExpiry();
1503
+ }, delay);
1504
+ }
1505
+ function noteSoftExpiry(id, at) {
1506
+ if (at === null) return;
1507
+ if (at <= now() && (store.snapshot().conversationSummaries.get(id)?.unreadCount ?? 0) === 0) {
1508
+ return;
1509
+ }
1510
+ const held = pendingExpiry.get(id);
1511
+ if (held !== void 0 && held <= at) return;
1512
+ pendingExpiry.set(id, at);
1513
+ scheduleExpiryTimer();
1514
+ }
1515
+ async function refreshPendingExpiries(ids) {
1516
+ const summaries = store.snapshot().conversationSummaries;
1517
+ const counted = ids.filter((id) => (summaries.get(id)?.unreadCount ?? 0) > 0);
1518
+ ids.forEach((id) => pendingExpiry.delete(id));
1519
+ if (counted.length > 0) {
1520
+ const pending = await repository.pendingSoftExpiries(counted);
1521
+ if (disposed) return;
1522
+ learnServerClock(pending.serverNow);
1523
+ pending.byConversation.forEach(
1524
+ (at, id) => noteSoftExpiry(id, softExpiryTime({ softExpiresAt: at }))
1525
+ );
1526
+ }
1527
+ scheduleExpiryTimer();
1528
+ }
1529
+ async function fireSoftExpiry() {
1530
+ if (disposed) return;
1531
+ const at = now();
1532
+ const due = [...pendingExpiry.entries()].filter(([, expiry]) => expiry <= at).map(([id]) => id);
1533
+ due.forEach((id) => pendingExpiry.delete(id));
1534
+ await Promise.all(
1535
+ due.map(async (id) => {
1536
+ if ((store.snapshot().conversationSummaries.get(id)?.unreadCount ?? 0) === 0) return;
1537
+ try {
1538
+ const count = await repository.unreadCount(id);
1539
+ if (!disposed) store.setUnreadCount(id, count);
1540
+ } catch (error) {
1541
+ reportError(error, "softExpiryUnread", { conversationId: id });
1542
+ }
1543
+ })
1544
+ );
1545
+ if (disposed) return;
1546
+ await refreshPendingExpiries(due).catch(
1547
+ (error) => reportError(error, "softExpiryRefresh")
1548
+ );
1549
+ }
1550
+ function watchSoftExpiries(ids) {
1551
+ void refreshPendingExpiries(ids).catch(
1552
+ (error) => reportError(error, "softExpiryRefresh")
1553
+ );
1554
+ }
1459
1555
  const outbox = createOutbox({
1460
1556
  ...options.outboxStorage !== void 0 ? { storage: options.outboxStorage } : {},
1461
1557
  send: (draft, clientMessageId) => repository.insertMessage(draft, clientMessageId),
@@ -1482,7 +1578,8 @@ function createMessagingEngine(options) {
1482
1578
  ...entry.draft.replyToId !== void 0 ? { replyToId: entry.draft.replyToId } : {},
1483
1579
  ...entry.draft.action !== void 0 ? { action: entry.draft.action } : {},
1484
1580
  ...entry.draft.attachments !== void 0 ? { attachments: [...entry.draft.attachments] } : {},
1485
- ...entry.draft.references !== void 0 ? { references: [...entry.draft.references] } : {}
1581
+ ...entry.draft.references !== void 0 ? { references: [...entry.draft.references] } : {},
1582
+ ...entry.draft.softExpiresAt !== void 0 ? { softExpiresAt: entry.draft.softExpiresAt } : {}
1486
1583
  })
1487
1584
  );
1488
1585
  });
@@ -1511,6 +1608,7 @@ function createMessagingEngine(options) {
1511
1608
  if (disposed) return;
1512
1609
  conversationCursor = page.nextCursor;
1513
1610
  store.setConversations(page.items, page.hasMore);
1611
+ watchSoftExpiries([...store.snapshot().conversationSummaries.keys()]);
1514
1612
  }
1515
1613
  async function reloadArchivedCount() {
1516
1614
  if (disposed) return;
@@ -1705,6 +1803,7 @@ function createMessagingEngine(options) {
1705
1803
  store.ingest(message);
1706
1804
  const isMine = message.senderId === identity.userId;
1707
1805
  store.applyLastMessage(message, { incrementUnread: !isMine });
1806
+ if (!isMine) noteSoftExpiry(message.conversationId, softExpiryTime(message));
1708
1807
  if (!isMine && store.snapshot().activeConversationId === message.conversationId) {
1709
1808
  void persistRead(message.conversationId).catch(() => void 0);
1710
1809
  }
@@ -1766,6 +1865,7 @@ function createMessagingEngine(options) {
1766
1865
  });
1767
1866
  conversationCursor = page.nextCursor;
1768
1867
  store.appendConversations(page.items, page.hasMore);
1868
+ watchSoftExpiries(page.items.map((item) => item.conversation.id));
1769
1869
  } catch (error) {
1770
1870
  reportError(error, "loadMoreConversations");
1771
1871
  }
@@ -1785,7 +1885,10 @@ function createMessagingEngine(options) {
1785
1885
  const directSummary = missingSummary ? await repository.getConversationSummary(id) : null;
1786
1886
  if (!openIsLive(id, generation, ownedChannel)) return;
1787
1887
  store.setConversationReadFailure(id, null);
1788
- if (directSummary !== null) store.setConversationSummary(directSummary);
1888
+ if (directSummary !== null) {
1889
+ store.setConversationSummary(directSummary);
1890
+ watchSoftExpiries([id]);
1891
+ }
1789
1892
  await persistRead(
1790
1893
  id,
1791
1894
  () => openIsLive(id, generation, ownedChannel)
@@ -1880,6 +1983,9 @@ function createMessagingEngine(options) {
1880
1983
  },
1881
1984
  dispose() {
1882
1985
  disposed = true;
1986
+ if (expiryTimer !== null) clearTimeout(expiryTimer);
1987
+ expiryTimer = null;
1988
+ pendingExpiry.clear();
1883
1989
  openChannels.forEach((_handle, id) => {
1884
1990
  initialReadGeneration.set(id, (initialReadGeneration.get(id) ?? 0) + 1);
1885
1991
  });
@@ -2141,10 +2247,13 @@ var RPCS = {
2141
2247
  /** Conversation list + participants + last message + unread, keyset paged. */
2142
2248
  conversationsWithDetails: "get_dm_conversations_with_details",
2143
2249
  unreadCount: "get_dm_unread_count",
2250
+ /** The soonest future soft expiry per conversation, plus the server's clock. */
2251
+ pendingSoftExpiries: "get_dm_pending_soft_expiries",
2144
2252
  userInfo: "get_dm_user_info",
2145
2253
  isParticipant: "is_dm_participant"
2146
2254
  };
2147
- 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";
2255
+ 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";
2256
+ var PENDING_SOFT_EXPIRY_CHUNK = 200;
2148
2257
  var PARTICIPANT_COLUMNS = "conversation_id,user_id,role,joined_at,last_read_at,is_muted,is_archived";
2149
2258
  var CONVERSATION_COLUMNS = "id,type,group_name,group_image_url,created_by,organization_id,created_at,updated_at,metadata";
2150
2259
  function optionalOrg(organizationId) {
@@ -2565,6 +2674,9 @@ function createMessagingRepository(options) {
2565
2674
  // confirmed row instead of showing the message twice.
2566
2675
  client_message_id: clientMessageId,
2567
2676
  action_data: draft.action ?? null,
2677
+ // SOFT EXPIRY: written only when the sender sets one. Lapsed is
2678
+ // derived from it live (`isLapsed`), never stamped.
2679
+ ...draft.softExpiresAt !== void 0 ? { soft_expires_at: draft.softExpiresAt } : {},
2568
2680
  metadata: {
2569
2681
  ...draft.metadata ?? {},
2570
2682
  ...draft.attachments !== void 0 && draft.attachments.length > 0 ? { attachments: draft.attachments } : {},
@@ -2617,6 +2729,56 @@ function createMessagingRepository(options) {
2617
2729
  }
2618
2730
  return Math.floor(unread);
2619
2731
  },
2732
+ async unreadCount(conversationId) {
2733
+ const operation = "unreadCount";
2734
+ const me = await actingUser(operation);
2735
+ const unread = await withSessionRetry(
2736
+ operation,
2737
+ () => rpc(
2738
+ RPCS.unreadCount,
2739
+ { p_conversation_id: conversationId, p_user_id: me },
2740
+ operation
2741
+ )
2742
+ );
2743
+ if (typeof unread !== "number" || !Number.isFinite(unread) || unread < 0) {
2744
+ throw invalidResponse(operation, `${RPCS.unreadCount} returned ${JSON.stringify(unread)}`);
2745
+ }
2746
+ return Math.floor(unread);
2747
+ },
2748
+ async pendingSoftExpiries(conversationIds) {
2749
+ const operation = "pendingSoftExpiries";
2750
+ const unique = [...new Set(conversationIds)];
2751
+ const byConversation = /* @__PURE__ */ new Map();
2752
+ let serverNow = null;
2753
+ for (let start = 0; start < unique.length; start += PENDING_SOFT_EXPIRY_CHUNK) {
2754
+ const chunk = unique.slice(start, start + PENDING_SOFT_EXPIRY_CHUNK);
2755
+ const answer = await withSessionRetry(
2756
+ operation,
2757
+ () => rpc(
2758
+ RPCS.pendingSoftExpiries,
2759
+ { p_conversation_ids: chunk },
2760
+ operation
2761
+ )
2762
+ );
2763
+ const now = answer?.["server_now"];
2764
+ const pending = answer?.["pending"];
2765
+ if (typeof now !== "string" || !Array.isArray(pending)) {
2766
+ throw invalidResponse(
2767
+ operation,
2768
+ `${RPCS.pendingSoftExpiries} returned ${JSON.stringify(answer)}`
2769
+ );
2770
+ }
2771
+ serverNow = now;
2772
+ for (const row of pending) {
2773
+ const id = row["conversation_id"];
2774
+ const at = row["soft_expires_at"];
2775
+ if (typeof id === "string" && typeof at === "string") {
2776
+ byConversation.set(id, at);
2777
+ }
2778
+ }
2779
+ }
2780
+ return { byConversation, serverNow };
2781
+ },
2620
2782
  async setConversationFlags(conversationId, flags) {
2621
2783
  const operation = "setConversationFlags";
2622
2784
  const me = await actingUser(operation);