@antzsoft/chat-core 1.3.2 → 1.3.4

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/README.md CHANGED
@@ -759,6 +759,7 @@ import { messagesApi } from '@antzsoft/chat-core';
759
759
  | `getLastRead` | `(conversationId: string) => Promise<{ lastReadMessageId: string \| null; lastReadAt: string \| null }>` | Fetch the current user's last-read pointer for a conversation. Use on initial load; after that the store is kept live by socket events. |
760
760
  | `markAsRead` | `(conversationId: string, messageId?: string) => Promise<void>` | Mark messages as read via REST. |
761
761
  | `getReceipts` | `(messageId: string) => Promise<MessageReceiptsResponse>` | Fetch per-user read and delivery receipts for a single message, with resolved user profiles (name, avatar). Use as the initial load for a message info / "Read by" detail screen. |
762
+ | `getReactions` | `(messageId: string) => Promise<MessageReactionsResponse>` | Fetch the full reaction breakdown for a single message — grouped by emoji with per-user details and `didIReact`. Use for a reactions detail sheet. |
762
763
  | `pin` | `(messageId: string) => Promise<Message>` | Pin a message. |
763
764
  | `unpin` | `(messageId: string) => Promise<Message>` | Unpin a message. |
764
765
  | `getPinned` | `(conversationId: string) => Promise<Message[]>` | List pinned messages in a conversation. |
@@ -1710,13 +1711,14 @@ import { syncApi } from '@antzsoft/chat-core';
1710
1711
  import type {
1711
1712
  CrossConversationSyncResponse,
1712
1713
  ConversationSyncResponse,
1714
+ SyncDeliveredReceipt,
1713
1715
  } from '@antzsoft/chat-core';
1714
1716
  ```
1715
1717
 
1716
1718
  | Method | Signature | Description |
1717
1719
  |---|---|---|
1718
1720
  | `pull` | `(since: string) => Promise<CrossConversationSyncResponse>` | Cross-conversation delta sync. Call on every socket reconnect. `since` is the `syncedAt` value returned by the previous call (server clock — avoids client clock skew). |
1719
- | `pullConversation` | `(conversationId: string, since?: string) => Promise<ConversationSyncResponse>` | Per-conversation delta sync. Call when a conversation has `needs_refresh = true`, or omit `since` on fresh install to receive full history. |
1721
+ | `pullConversation` | `(conversationId: string, opts?: { since?: string; afterSeq?: number; beforeSeq?: number }) => Promise<ConversationSyncResponse>` | Per-conversation sync. Prefer `afterSeq` (forward catch-up) or `beforeSeq` (backward scroll). Falls back to `since` timestamp for older servers. |
1720
1722
 
1721
1723
  #### When to call each
1722
1724
 
@@ -1724,21 +1726,27 @@ import type {
1724
1726
  |---|---|
1725
1727
  | Socket reconnects (within stale threshold) | `syncApi.pull(lastSyncedAt)` — returns all cross-conversation changes |
1726
1728
  | Socket reconnects (gap > stale threshold) | `pull()` returns `stale: true` — mark all conversations `needs_refresh`, lazy-sync on open |
1727
- | User opens a stale conversation | `syncApi.pullConversation(convId, lastSyncedAt)` returns full delta for that conversation |
1728
- | Fresh install / first open | `syncApi.pullConversation(convId)` omit `since` to receive full history |
1729
+ | User opens a stale conversation (catch-up) | `syncApi.pullConversation(convId, { afterSeq: lastKnownSeq })` |
1730
+ | User scrolls back into unloaded history | `syncApi.pullConversation(convId, { beforeSeq: oldestLocalSeq })` |
1731
+ | Resume interrupted sync | Pass the `nextSeq` from the previous page as the same cursor type |
1732
+ | First sync / old server | `syncApi.pullConversation(convId, { since: lastSyncedAt })` |
1729
1733
 
1730
1734
  #### `CrossConversationSyncResponse`
1731
1735
 
1732
- Returned by `syncApi.pull(since)`. Does **not** include reactions or stars — those are per-conversation and returned by `pullConversation`.
1736
+ Returned by `syncApi.pull(since)`. As of v1.3.1, also includes `reactions` and `stars`.
1733
1737
 
1734
1738
  ```typescript
1735
1739
  interface CrossConversationSyncResponse {
1736
- syncedAt: string; // server-stamped ISO timestamp — store as new lastSyncedAt
1737
- stale: boolean; // true when gap > CHAT_SYNC_STALE_DAYS (default 30) — app should lazy-sync on open
1738
- messages: Message[]; // edits, deletes, pins, unpins — any message with updatedAt > since
1740
+ syncedAt: string; // server-stamped ISO timestamp — store as new lastSyncedAt
1741
+ stale: boolean; // true when gap > CHAT_SYNC_STALE_DAYS (default 30)
1742
+ messages: Message[]; // edits, deletes, pins, unpins — any message with updatedAt > since
1739
1743
  deletedForMe: SyncDeletedForMe[];
1740
1744
  participantChanges: SyncParticipantChange[];
1741
1745
  readReceipts: SyncReadReceipt[];
1746
+ deliveredReceipts: SyncDeliveredReceipt[]; // delivery receipts since `since`
1747
+ reactions: SyncReactions; // v1.3.1+ — full current state for messages with reaction activity
1748
+ stars: SyncStarEntry[]; // v1.3.1+ — star/unstar records since `since`
1749
+ deletedConversationIds: string[]; // v1.3.4+ — conversations the user deleted from their list since `since`
1742
1750
  }
1743
1751
 
1744
1752
  interface SyncDeletedForMe {
@@ -1763,25 +1771,44 @@ interface SyncReadReceipt {
1763
1771
  userId: string;
1764
1772
  readAt: string | null;
1765
1773
  }
1774
+
1775
+ interface SyncDeliveredReceipt {
1776
+ messageId: string;
1777
+ conversationId: string;
1778
+ userId: string;
1779
+ deliveredAt: string | null;
1780
+ }
1766
1781
  ```
1767
1782
 
1768
1783
  #### `ConversationSyncResponse`
1769
1784
 
1770
- Returned by `syncApi.pullConversation(convId, since?)`. Includes everything needed to make local SQLite authoritative for that conversation.
1785
+ Returned by `syncApi.pullConversation(convId, opts?)`. Supports seq-based pagination in both directions call repeatedly following `hasMore` / `nextSeq` until `hasMore` is `false`.
1771
1786
 
1772
1787
  ```typescript
1773
1788
  interface ConversationSyncResponse {
1774
1789
  syncedAt: string;
1775
- messages: Message[]; // updatedAt > sinceedits, deletes, pins
1790
+ /** Cursor for the next page pass as afterSeq or beforeSeq matching what you sent */
1791
+ nextSeq?: number;
1792
+ /** True when more pages are available — call again with nextSeq as the same cursor type */
1793
+ hasMore?: boolean;
1794
+ messages: Message[];
1776
1795
  deletedForMe: SyncDeletedForMe[];
1777
- reactions: SyncReactions; // full current state for messages with reactedAt > since
1778
- stars: SyncStarEntry[]; // updatedAt > since; isActive: false = unstarred
1796
+ reactions: SyncReactions; // full current state for affected messages
1797
+ stars: SyncStarEntry[]; // isActive: false = unstarred
1779
1798
  participantChanges: SyncParticipantChange[];
1780
1799
  readReceipts: SyncReadReceipt[];
1800
+ deliveredReceipts: SyncDeliveredReceipt[];
1781
1801
  }
1782
1802
 
1783
- // keyed by messageId — full current emoji counts (not delta)
1784
- type SyncReactions = Record<string, MessageReaction[]>;
1803
+ // keyed by messageId — bounded shape: emoji, count, didIReact only (v1.3.2+)
1804
+ // Full user list: call messagesApi.getReactions(messageId) on demand
1805
+ type SyncReactions = Record<string, SyncReactionEntry[]>;
1806
+
1807
+ interface SyncReactionEntry {
1808
+ emoji: string;
1809
+ count: number;
1810
+ didIReact: boolean; // true when the calling user has reacted with this emoji
1811
+ }
1785
1812
 
1786
1813
  interface SyncStarEntry {
1787
1814
  messageId: string;
@@ -1791,16 +1818,51 @@ interface SyncStarEntry {
1791
1818
  }
1792
1819
  ```
1793
1820
 
1821
+ #### Seq-based pagination
1822
+
1823
+ `pullConversation` supports two directions. Always upsert each page to SQLite before requesting the next — this gives the user visible messages immediately and provides a safe resume point.
1824
+
1825
+ **Forward (catch-up) — oldest → newest:**
1826
+ ```typescript
1827
+ // Use Conversation.lastSeq from the conversation list as the starting cursor
1828
+ let afterSeq = conversation.lastSeq ?? 0;
1829
+
1830
+ while (true) {
1831
+ const res = await syncApi.pullConversation(conversationId, { afterSeq });
1832
+ await upsertPageToSQLite(res);
1833
+ if (!res.hasMore || res.nextSeq == null) break;
1834
+ afterSeq = res.nextSeq;
1835
+ }
1836
+ ```
1837
+
1838
+ **Backward (scroll-back) — newest → oldest:**
1839
+ ```typescript
1840
+ let beforeSeq: number | null = null; // null = start from the latest messages
1841
+
1842
+ while (true) {
1843
+ const res = await syncApi.pullConversation(conversationId,
1844
+ beforeSeq != null ? { beforeSeq } : undefined,
1845
+ );
1846
+ await upsertPageToSQLite(res);
1847
+ if (!res.hasMore || res.nextSeq == null) break;
1848
+ beforeSeq = res.nextSeq;
1849
+ }
1850
+ ```
1851
+
1852
+ The built-in `SyncService.syncConversation()` in the RN app uses the backward direction and handles the loop, cancellation on unmount, and cursor persistence automatically.
1853
+
1794
1854
  #### What each field covers
1795
1855
 
1796
- | Field | What changed events it captures |
1797
- |---|---|
1798
- | `messages` | Edits (`isEdited: true`), deletes (`status: "deleted"`), pins / unpins (`isPinned`) |
1799
- | `deletedForMe` | Messages this user hid for themselves only — remove from local DB |
1800
- | `reactions` | Full current emoji counts for any message that had reaction activity. Upsert as ground truth never merge |
1801
- | `stars` | `isActive: true` = starred, `isActive: false` = unstarred — delete local star record when false |
1802
- | `participantChanges` | Role changes, mute/unmute, removals (`isActive: false`) |
1803
- | `readReceipts` | Read receipts received since `since` |
1856
+ | Field | `pull()` | `pullConversation()` | What it captures |
1857
+ |---|---|---|---|
1858
+ | `messages` | | ✅ | Edits, deletes, pins/unpins. Each message includes `metadata`, `sender`, and signed attachment `url`s |
1859
+ | `deletedForMe` | ✅ | ✅ | Messages this user hid — remove from local DB |
1860
+ | `reactions` | v1.3.1 | | Full current `{ emoji, count, didIReact }` for affected messages. **Replace, never merge** |
1861
+ | `stars` | v1.3.1 | | `isActive: false` = unstarred — delete local star record |
1862
+ | `participantChanges` | ✅ | ✅ | Role changes, mute/unmute, removals (`isActive: false`) |
1863
+ | `readReceipts` | ✅ | ✅ | Read receipts merge into `msg.readBy[]`, recompute status |
1864
+ | `deliveredReceipts` | ✅ | ✅ | Delivery receipts — merge into `msg.deliveredTo[]`, recompute status |
1865
+ | `deletedConversationIds` | ✅ v1.3.4 | — | Conversations this user deleted from their list — remove from local DB. Cursor-safe: won't reappear on next `pull()` |
1804
1866
 
1805
1867
  #### Stale handling
1806
1868
 
@@ -1861,9 +1923,10 @@ useEffect(() => {
1861
1923
  | Message deleted (everyone) | ✅ | ✅ |
1862
1924
  | Message deleted for me | ✅ | ✅ |
1863
1925
  | Message pinned / unpinned | ✅ | ✅ |
1864
- | Reaction added / removed | | ✅ full current state |
1865
- | Star / unstar | | ✅ with `isActive` |
1926
+ | Reaction added / removed | v1.3.1+ | ✅ |
1927
+ | Star / unstar | v1.3.1+ | ✅ with `isActive` |
1866
1928
  | Read receipt | ✅ | ✅ |
1929
+ | Delivery receipt | ✅ | ✅ |
1867
1930
  | Role change / mute / removal | ✅ | ✅ |
1868
1931
 
1869
1932
  ---
@@ -2532,6 +2595,7 @@ interface Message {
2532
2595
  pinnedBy?: string;
2533
2596
  pinnedAt?: string;
2534
2597
  uploadProgress?: number; // 0–100, present on optimistic messages
2598
+ seq?: number; // v1.3.0+ — monotonic per-conversation counter; use as afterSeq cursor
2535
2599
  sentAt: string;
2536
2600
  createdAt: string;
2537
2601
  sender?: User;
@@ -2614,6 +2678,41 @@ const receipts = await messagesApi.getReceipts(messageId);
2614
2678
 
2615
2679
  ---
2616
2680
 
2681
+ ### `MessageReactionsResponse`
2682
+
2683
+ Returned by `messagesApi.getReactions()`. Use as the data source for a reactions detail sheet (list of who reacted with each emoji).
2684
+
2685
+ ```typescript
2686
+ interface ReactionUser {
2687
+ userId: string;
2688
+ displayName: string;
2689
+ avatarUrl?: string;
2690
+ }
2691
+
2692
+ interface ReactionGroup {
2693
+ emoji: string;
2694
+ userIds: string[]; // all user IDs who reacted with this emoji
2695
+ count: number;
2696
+ users: ReactionUser[]; // resolved displayName + avatarUrl
2697
+ }
2698
+
2699
+ interface MessageReactionsResponse {
2700
+ messageId: string;
2701
+ reactions: ReactionGroup[];
2702
+ }
2703
+ ```
2704
+
2705
+ ```typescript
2706
+ // Load reaction breakdown for a detail sheet
2707
+ const data = await messagesApi.getReactions(messageId);
2708
+ for (const group of data.reactions) {
2709
+ // group.emoji, group.count, group.users (with displayName + avatarUrl)
2710
+ renderReactionSheet(group);
2711
+ }
2712
+ ```
2713
+
2714
+ ---
2715
+
2617
2716
  ### `MessageContent`
2618
2717
 
2619
2718
  ```typescript
@@ -2819,6 +2918,7 @@ interface MessageAckEvent {
2819
2918
  tempId: string; // the client-generated ID you sent
2820
2919
  messageId: string; // the server-assigned real ID
2821
2920
  status: MessageStatus;
2921
+ seq?: number; // v1.3.0+ — seq assigned by the server; persist as last known seq for this conversation
2822
2922
  }
2823
2923
 
2824
2924
  interface MessageDeliveredEvent {
@@ -2956,6 +3056,175 @@ document.querySelectorAll('[data-conv-id]').forEach((el) => {
2956
3056
 
2957
3057
  ## Changelog
2958
3058
 
3059
+ ### v1.3.4
3060
+
3061
+ - **New: `deletedConversationIds` in `CrossConversationSyncResponse`** — `syncApi.pull(since)` now returns `deletedConversationIds: string[]`. Contains the IDs of conversations this user explicitly deleted from their list (via the delete-conversation action) since the `since` cursor. The client should remove these from local SQLite and the UI conversation list.
3062
+
3063
+ ```typescript
3064
+ const result = await syncApi.pull(lastSyncedAt);
3065
+
3066
+ // Remove conversations the user deleted from their list
3067
+ for (const convId of result.deletedConversationIds) {
3068
+ await db.conversations.delete(convId);
3069
+ }
3070
+ ```
3071
+
3072
+ **Scoped to the requesting user only** — if another user deletes their copy of a shared DM, it does not appear in your `deletedConversationIds`. **Cursor-safe** — once the client saves the returned `syncedAt` as the new cursor, the same IDs will not reappear on the next `pull()` call. On the server side, this is detected via `participant.isHidden = true AND updatedAt > since` — the same flag set by `DELETE /conversations/:id`.
3073
+
3074
+ - **Fix: Sync access guard parity with message listing API** — All three sync paths now enforce the same access rules as `GET /messages`:
3075
+ - **`membershipPeriods` windows** — users who deleted and were re-added only see messages from their active membership periods; a deleted conversation with no periods returns no messages.
3076
+ - **`status` filter** — active messages and deleted-for-everyone tombstones (so clients can render "This message was deleted") are included; `failed`-status messages are excluded.
3077
+ - **`isHidden` group check** — hidden group participants (exit+delete) receive `403 Forbidden` on per-conversation sync, matching message listing behaviour. Hidden DM participants are still allowed.
3078
+ - **Removed-member read access** — removed members (`isActive: false`) can still call per-conversation sync to retrieve history, matching the message listing API's `validateConversationAccess` which does not require `isActive: true` for reads.
3079
+ - **No integration changes required.** These are server-side enforcement fixes — the response shape is unchanged, only the set of messages returned is now correctly filtered.
3080
+
3081
+ ### v1.3.3
3082
+
3083
+ - **New: `metadata` and `sender` fields in all sync message responses** — Messages returned by all three sync paths (`crossConversationSync`, `conversationSync`, `conversationSyncBySeq`) now include `metadata` (system message metadata — `actorUserId`, `targetUserId`, `action`) and `sender` (displayName, avatarUrl snapshot at send time). Both fields were already optional on the core SDK `Message` type so no SDK type changes were required. Previously absent from sync responses, meaning system message text could not be rendered and sender avatars were blank after an offline sync. The server batch-fetches sender profiles in a single `findByIds` call per sync request — one extra DB round trip per sync, not one per message. **No integration changes required.**
3084
+
3085
+ ### v1.3.2
3086
+
3087
+ - **New: Bounded sync reaction payloads — `SyncReactionEntry` type replaces `MessageReaction[]`** — Previously, `SyncReactions` (the `reactions` field in `ConversationSyncResponse` and `CrossConversationSyncResponse`) was `Record<string, MessageReaction[]>` where `MessageReaction.userIds` was an unbounded string array. On high-traffic messages with hundreds of reactions, this could blow past the 4 MB adaptive page limit. The type is now `Record<string, SyncReactionEntry[]>` where each entry carries only `emoji`, `count`, and `didIReact` (whether the calling user reacted). Full user lists are fetched on demand via `messagesApi.getReactions()`. **Sync payload size is now O(messages × emojis) regardless of reaction count.**
3088
+
3089
+ ```typescript
3090
+ // Old shape (removed)
3091
+ // reactions: Record<string, MessageReaction[]> // MessageReaction had userIds: string[]
3092
+
3093
+ // New shape
3094
+ interface SyncReactionEntry {
3095
+ emoji: string;
3096
+ count: number;
3097
+ didIReact: boolean; // true when the calling user has reacted with this emoji
3098
+ }
3099
+ type SyncReactions = Record<string, SyncReactionEntry[]>; // keyed by messageId
3100
+ ```
3101
+
3102
+ **Migration** — clients reading `reactions[msgId][n].userIds` must switch to `reactions[msgId][n].count` and `didIReact`. For a full user list (e.g. "Who reacted" sheet), call `messagesApi.getReactions(messageId)`.
3103
+
3104
+ - **New: `messagesApi.getReactions(messageId)` — reactions detail sheet API** — New `POST /messages/:id/reactions/list` endpoint (GET mirror also available) returns the full per-emoji breakdown for a single message: counts, all reacting user IDs, and resolved user profiles. Use as the data source for a "Who reacted" bottom sheet. **New exported types: `MessageReactionsResponse`, `ReactionGroup`, `ReactionUser`.**
3105
+
3106
+ ```typescript
3107
+ interface ReactionGroup {
3108
+ emoji: string;
3109
+ userIds: string[]; // all user IDs who reacted with this emoji
3110
+ count: number;
3111
+ users: ReactionUser[]; // resolved displayName + avatarUrl for each userId
3112
+ }
3113
+
3114
+ interface MessageReactionsResponse {
3115
+ messageId: string;
3116
+ reactions: ReactionGroup[];
3117
+ }
3118
+ ```
3119
+
3120
+ - **Improvement: Adaptive page-halving in `conversationSyncBySeq`** — The seq-based sync endpoint now estimates response size before returning and halves the page limit (min 25) when the estimated payload would exceed 4 MB. Triggered only for pathological cases (message with hundreds of reactions). `SYNC_PAGE_LIMIT` reduced from 200 to 100. **No integration changes required.**
3121
+
3122
+ - **No breaking changes** — `SyncReactions` shape change is the only client-visible change; see migration note above.
3123
+
3124
+ ### v1.3.1
3125
+
3126
+ - **New: Bidirectional seq pagination in `conversationSyncBySeq`** — The per-conversation seq-based sync endpoint now supports both directions:
3127
+
3128
+ | Direction | Param | Order | Use case |
3129
+ |---|---|---|---|
3130
+ | Forward (default) | `afterSeq` | oldest → newest | catch-up / sync on reconnect |
3131
+ | Backward | `beforeSeq` | newest → oldest | scroll-back / load older history |
3132
+
3133
+ Pass `beforeSeq` (exclusive upper bound) to page backward. The server validates mutual exclusivity — passing both is a `400 Bad Request`. The `nextSeq` in the response is the cursor for the next page in the same direction.
3134
+
3135
+ ```typescript
3136
+ // Forward catch-up (default behavior, unchanged)
3137
+ const res = await syncApi.pullConversation(conversationId, { afterSeq: lastKnownSeq });
3138
+
3139
+ // Backward scroll (new)
3140
+ const res = await syncApi.pullConversation(conversationId, { beforeSeq: oldestLocalSeq });
3141
+ if (res.hasMore) {
3142
+ const nextPage = await syncApi.pullConversation(conversationId, { beforeSeq: res.nextSeq! });
3143
+ }
3144
+ ```
3145
+
3146
+ - **New: `reactions` and `stars` in `CrossConversationSyncResponse`** — `syncApi.pull(since)` now returns `reactions: SyncReactions` and `stars: SyncStarEntry[]` alongside the existing fields. These were previously only available from `pullConversation`. Cross-conversation catch-up now covers the full delta including reaction and star changes since the cursor.
3147
+
3148
+ ```typescript
3149
+ // CrossConversationSyncResponse now includes:
3150
+ reactions: SyncReactions; // full current state for messages with reaction activity > since
3151
+ stars: SyncStarEntry[]; // star/unstar records since since; isActive: false = unstarred
3152
+ ```
3153
+
3154
+ **Shape consistency** — stale responses and empty-conversations early returns now also include `reactions: {}` and `stars: []` so the response shape is always uniform.
3155
+
3156
+ - **New: `useConversationSync` direction option (RN SDK)** — The `@antzsoft/chat-rn-sdk` `useConversationSync` hook accepts a `direction` option (`'forward' | 'backward'`, default `'forward'`). Pass `direction: 'backward'` with `beforeSeq` to trigger a scroll-back page load. The hook automatically uses the stored seq cursor for forward syncs.
3157
+
3158
+ - **No breaking changes** — `pullConversation` opts are additive. `CrossConversationSyncResponse` gains two new fields that clients should merge the same way as `ConversationSyncResponse`.
3159
+
3160
+ ### v1.3.0
3161
+
3162
+ - **New: `seq` and `lastSeq` — per-conversation monotonic message sequence numbers** — Every message now carries a `seq` field: a per-conversation monotonically increasing integer assigned at send time. The server maintains an atomic Redis counter per conversation (`chat:seq:{conversationId}`), flushed to MongoDB every 60 s. `seq` appears on all message responses (REST and socket), on `Message.seq`, and on the `MessageAckEvent.seq` field.
3163
+
3164
+ `Conversation.lastSeq` is the highest `seq` seen in that conversation — returned by all conversation list/get endpoints and updated on every `conversation_updated` socket event via the new `ConversationUpdatedEvent.lastSeq` field. Use it as the `afterSeq` cursor for `pullConversation` to catch up on exactly the messages you missed.
3165
+
3166
+ ```typescript
3167
+ // New fields on existing types:
3168
+ interface Message {
3169
+ seq?: number; // monotonic per-conversation counter
3170
+ // ...
3171
+ }
3172
+
3173
+ interface Conversation {
3174
+ lastSeq?: number; // highest seq in this conversation — use as afterSeq cursor
3175
+ // ...
3176
+ }
3177
+
3178
+ interface MessageAckEvent {
3179
+ seq?: number; // seq assigned to this message by the server
3180
+ // ...
3181
+ }
3182
+
3183
+ interface ConversationUpdatedEvent { // NEW type (v1.3.0)
3184
+ id: string;
3185
+ lastMessage?: Partial<Message> | null;
3186
+ unreadCount?: number;
3187
+ updatedAt?: string;
3188
+ lastSeq?: number; // advance your afterSeq cursor on every message
3189
+ participants?: Participant[];
3190
+ }
3191
+ ```
3192
+
3193
+ - **New: `conversationSyncBySeq` server endpoint** — `GET /conversations/:id/sync?afterSeq=N` returns the next page of messages starting from seq N+1 (forward direction). Prefer this over timestamp-based sync when `Conversation.lastSeq` is available — seq cursors are collision-safe, never require a clock comparison, and enable exact-page pagination. Returns `hasMore: boolean` and `nextSeq: number | null` for looping.
3194
+
3195
+ - **New: `useConvSeq` / `setConvSeq` (RN SDK)** — `@antzsoft/chat-rn-sdk` exports `getConvSeq(conversationId)` and `setConvSeq(conversationId, seq)` for persisting the per-conversation seq cursor in AsyncStorage. `useConversationSync` in the RN SDK now automatically uses the stored seq for forward syncs — no code changes needed in the host app.
3196
+
3197
+ - **Migration 006** — `006_backfill_message_seq` runs automatically on first deploy. Assigns sequential `seq` values to all existing messages in each conversation (ordered by `createdAt`). Idempotent — safe to run multiple times.
3198
+
3199
+ - **New exported type: `ConversationUpdatedEvent`.**
3200
+
3201
+ - **No breaking changes** — `seq` and `lastSeq` are additive optional fields. Clients that don't use them are unaffected.
3202
+
3203
+ ### v1.2.9
3204
+
3205
+ - **New: `deliveredReceipts[]` in sync responses — delivery tick marks now survive offline gaps** — Both `CrossConversationSyncResponse` (from `syncApi.pull()`) and `ConversationSyncResponse` (from `syncApi.pullConversation()`) now include a `deliveredReceipts: SyncDeliveredReceipt[]` field. This mirrors the existing `readReceipts[]` pattern and is sourced from the `chat_message_deliveries` time-series collection. Mobile apps merge these into each message's `deliveredTo[]` array and recompute `status` via `resolveMessageStatus` — single-tick → double-tick transitions that occurred while offline are now correctly replayed on reconnect.
3206
+
3207
+ ```typescript
3208
+ interface SyncDeliveredReceipt {
3209
+ messageId: string;
3210
+ conversationId: string;
3211
+ userId: string;
3212
+ deliveredAt: string | null;
3213
+ }
3214
+ ```
3215
+
3216
+ **No integration changes required** — the `SyncService.applyShared()` in the RN app already handles `deliveredReceipts` via `ChatRepository.applyDeliveredReceipts()`. The field is absent on older server versions and is safely ignored by any older client. **New exported type: `SyncDeliveredReceipt`.**
3217
+
3218
+ - **Fix: Attachment URLs in sync messages are now properly signed** — Previously, sync responses (`pullAll` / `pullConversation`) returned raw `storageKey`, `provider`, and `bucket` fields for attachment records. These fields are internal storage identifiers — clients cannot use them to render images or play audio. The sync service now generates signed, short-lived `url`s for all attachments (same Redis-cached signing logic as the message list API, 2400 s cache / 3600 s expiry). The raw storage fields are stripped from the sync response. **Clients that were silently ignoring `storageKey` now receive a working `url`.**
3219
+
3220
+ - **Fix: `replyTo.attachmentSnapshot.url` now signed in sync** — The `replyTo.attachmentSnapshot` object (thumbnail of the quoted attachment shown in reply bubbles) was also missing a signed `url` in sync responses. The attachment snapshot now carries a properly signed `url` alongside its `type`, `filename`, `mimeType`, `size`, `duration`, and `dimensions`. `storageKey` / `provider` / `bucket` are not included in the snapshot output.
3221
+
3222
+ - **Fix: `replyTo.messageId` now serialized as a string in sync** — `replyTo.messageId` was returned as a MongoDB `ObjectId` object in sync responses due to a missing `.toString()` call on the lean document. It is now always a plain string, consistent with all other ID fields. No client-side workaround needed.
3223
+
3224
+ - **Fix: `applyReadReceipts` now recomputes `status` after merge** — The mobile `ChatRepository.applyReadReceipts()` function was merging the new read entries into `msg.readBy[]` but not re-running `resolveMessageStatus`. Messages whose `readBy` grew to include all recipients after a sync were not being promoted to `status: 'read'` — tick marks stayed at double-tick even when all recipients had read the message. Fixed by calling `resolveMessageStatus(updated)` and persisting the result after every merge.
3225
+
3226
+ - **No breaking changes** — all additions are purely additive. `deliveredReceipts` is absent on older server versions and treated as an empty array by any client. Existing sync handling, socket events, and API contracts are unchanged.
3227
+
2959
3228
  ### v1.2.8
2960
3229
 
2961
3230
  - **New: `deliveryStatus` on `Conversation.lastMessage`** — The conversation list API (`GET /conversations`) and the `conversation_updated` socket event now include `deliveryStatus` on the `lastMessage` object. This is the tick state (`'sent'` | `'delivered'` | `'read'`) of the last message as seen by the sender. It is computed and stored directly on the conversation document — no extra queries at list time.