@antzsoft/chat-core 1.3.2 → 1.3.3

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,26 @@ 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`
1742
1749
  }
1743
1750
 
1744
1751
  interface SyncDeletedForMe {
@@ -1763,25 +1770,44 @@ interface SyncReadReceipt {
1763
1770
  userId: string;
1764
1771
  readAt: string | null;
1765
1772
  }
1773
+
1774
+ interface SyncDeliveredReceipt {
1775
+ messageId: string;
1776
+ conversationId: string;
1777
+ userId: string;
1778
+ deliveredAt: string | null;
1779
+ }
1766
1780
  ```
1767
1781
 
1768
1782
  #### `ConversationSyncResponse`
1769
1783
 
1770
- Returned by `syncApi.pullConversation(convId, since?)`. Includes everything needed to make local SQLite authoritative for that conversation.
1784
+ Returned by `syncApi.pullConversation(convId, opts?)`. Supports seq-based pagination in both directions call repeatedly following `hasMore` / `nextSeq` until `hasMore` is `false`.
1771
1785
 
1772
1786
  ```typescript
1773
1787
  interface ConversationSyncResponse {
1774
1788
  syncedAt: string;
1775
- messages: Message[]; // updatedAt > sinceedits, deletes, pins
1789
+ /** Cursor for the next page pass as afterSeq or beforeSeq matching what you sent */
1790
+ nextSeq?: number;
1791
+ /** True when more pages are available — call again with nextSeq as the same cursor type */
1792
+ hasMore?: boolean;
1793
+ messages: Message[];
1776
1794
  deletedForMe: SyncDeletedForMe[];
1777
- reactions: SyncReactions; // full current state for messages with reactedAt > since
1778
- stars: SyncStarEntry[]; // updatedAt > since; isActive: false = unstarred
1795
+ reactions: SyncReactions; // full current state for affected messages
1796
+ stars: SyncStarEntry[]; // isActive: false = unstarred
1779
1797
  participantChanges: SyncParticipantChange[];
1780
1798
  readReceipts: SyncReadReceipt[];
1799
+ deliveredReceipts: SyncDeliveredReceipt[];
1781
1800
  }
1782
1801
 
1783
- // keyed by messageId — full current emoji counts (not delta)
1784
- type SyncReactions = Record<string, MessageReaction[]>;
1802
+ // keyed by messageId — bounded shape: emoji, count, didIReact only (v1.3.2+)
1803
+ // Full user list: call messagesApi.getReactions(messageId) on demand
1804
+ type SyncReactions = Record<string, SyncReactionEntry[]>;
1805
+
1806
+ interface SyncReactionEntry {
1807
+ emoji: string;
1808
+ count: number;
1809
+ didIReact: boolean; // true when the calling user has reacted with this emoji
1810
+ }
1785
1811
 
1786
1812
  interface SyncStarEntry {
1787
1813
  messageId: string;
@@ -1791,16 +1817,50 @@ interface SyncStarEntry {
1791
1817
  }
1792
1818
  ```
1793
1819
 
1820
+ #### Seq-based pagination
1821
+
1822
+ `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.
1823
+
1824
+ **Forward (catch-up) — oldest → newest:**
1825
+ ```typescript
1826
+ // Use Conversation.lastSeq from the conversation list as the starting cursor
1827
+ let afterSeq = conversation.lastSeq ?? 0;
1828
+
1829
+ while (true) {
1830
+ const res = await syncApi.pullConversation(conversationId, { afterSeq });
1831
+ await upsertPageToSQLite(res);
1832
+ if (!res.hasMore || res.nextSeq == null) break;
1833
+ afterSeq = res.nextSeq;
1834
+ }
1835
+ ```
1836
+
1837
+ **Backward (scroll-back) — newest → oldest:**
1838
+ ```typescript
1839
+ let beforeSeq: number | null = null; // null = start from the latest messages
1840
+
1841
+ while (true) {
1842
+ const res = await syncApi.pullConversation(conversationId,
1843
+ beforeSeq != null ? { beforeSeq } : undefined,
1844
+ );
1845
+ await upsertPageToSQLite(res);
1846
+ if (!res.hasMore || res.nextSeq == null) break;
1847
+ beforeSeq = res.nextSeq;
1848
+ }
1849
+ ```
1850
+
1851
+ The built-in `SyncService.syncConversation()` in the RN app uses the backward direction and handles the loop, cancellation on unmount, and cursor persistence automatically.
1852
+
1794
1853
  #### What each field covers
1795
1854
 
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` |
1855
+ | Field | `pull()` | `pullConversation()` | What it captures |
1856
+ |---|---|---|---|
1857
+ | `messages` | | ✅ | Edits, deletes, pins/unpins. Each message includes `metadata`, `sender`, and signed attachment `url`s |
1858
+ | `deletedForMe` | ✅ | ✅ | Messages this user hid — remove from local DB |
1859
+ | `reactions` | v1.3.1 | | Full current `{ emoji, count, didIReact }` for affected messages. **Replace, never merge** |
1860
+ | `stars` | v1.3.1 | | `isActive: false` = unstarred — delete local star record |
1861
+ | `participantChanges` | ✅ | ✅ | Role changes, mute/unmute, removals (`isActive: false`) |
1862
+ | `readReceipts` | ✅ | ✅ | Read receipts merge into `msg.readBy[]`, recompute status |
1863
+ | `deliveredReceipts` | ✅ | ✅ | Delivery receipts — merge into `msg.deliveredTo[]`, recompute status |
1804
1864
 
1805
1865
  #### Stale handling
1806
1866
 
@@ -1861,9 +1921,10 @@ useEffect(() => {
1861
1921
  | Message deleted (everyone) | ✅ | ✅ |
1862
1922
  | Message deleted for me | ✅ | ✅ |
1863
1923
  | Message pinned / unpinned | ✅ | ✅ |
1864
- | Reaction added / removed | | ✅ full current state |
1865
- | Star / unstar | | ✅ with `isActive` |
1924
+ | Reaction added / removed | v1.3.1+ | ✅ |
1925
+ | Star / unstar | v1.3.1+ | ✅ with `isActive` |
1866
1926
  | Read receipt | ✅ | ✅ |
1927
+ | Delivery receipt | ✅ | ✅ |
1867
1928
  | Role change / mute / removal | ✅ | ✅ |
1868
1929
 
1869
1930
  ---
@@ -2532,6 +2593,7 @@ interface Message {
2532
2593
  pinnedBy?: string;
2533
2594
  pinnedAt?: string;
2534
2595
  uploadProgress?: number; // 0–100, present on optimistic messages
2596
+ seq?: number; // v1.3.0+ — monotonic per-conversation counter; use as afterSeq cursor
2535
2597
  sentAt: string;
2536
2598
  createdAt: string;
2537
2599
  sender?: User;
@@ -2614,6 +2676,41 @@ const receipts = await messagesApi.getReceipts(messageId);
2614
2676
 
2615
2677
  ---
2616
2678
 
2679
+ ### `MessageReactionsResponse`
2680
+
2681
+ Returned by `messagesApi.getReactions()`. Use as the data source for a reactions detail sheet (list of who reacted with each emoji).
2682
+
2683
+ ```typescript
2684
+ interface ReactionUser {
2685
+ userId: string;
2686
+ displayName: string;
2687
+ avatarUrl?: string;
2688
+ }
2689
+
2690
+ interface ReactionGroup {
2691
+ emoji: string;
2692
+ userIds: string[]; // all user IDs who reacted with this emoji
2693
+ count: number;
2694
+ users: ReactionUser[]; // resolved displayName + avatarUrl
2695
+ }
2696
+
2697
+ interface MessageReactionsResponse {
2698
+ messageId: string;
2699
+ reactions: ReactionGroup[];
2700
+ }
2701
+ ```
2702
+
2703
+ ```typescript
2704
+ // Load reaction breakdown for a detail sheet
2705
+ const data = await messagesApi.getReactions(messageId);
2706
+ for (const group of data.reactions) {
2707
+ // group.emoji, group.count, group.users (with displayName + avatarUrl)
2708
+ renderReactionSheet(group);
2709
+ }
2710
+ ```
2711
+
2712
+ ---
2713
+
2617
2714
  ### `MessageContent`
2618
2715
 
2619
2716
  ```typescript
@@ -2819,6 +2916,7 @@ interface MessageAckEvent {
2819
2916
  tempId: string; // the client-generated ID you sent
2820
2917
  messageId: string; // the server-assigned real ID
2821
2918
  status: MessageStatus;
2919
+ seq?: number; // v1.3.0+ — seq assigned by the server; persist as last known seq for this conversation
2822
2920
  }
2823
2921
 
2824
2922
  interface MessageDeliveredEvent {
@@ -2956,6 +3054,153 @@ document.querySelectorAll('[data-conv-id]').forEach((el) => {
2956
3054
 
2957
3055
  ## Changelog
2958
3056
 
3057
+ ### v1.3.3
3058
+
3059
+ - **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.**
3060
+
3061
+ ### v1.3.2
3062
+
3063
+ - **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.**
3064
+
3065
+ ```typescript
3066
+ // Old shape (removed)
3067
+ // reactions: Record<string, MessageReaction[]> // MessageReaction had userIds: string[]
3068
+
3069
+ // New shape
3070
+ interface SyncReactionEntry {
3071
+ emoji: string;
3072
+ count: number;
3073
+ didIReact: boolean; // true when the calling user has reacted with this emoji
3074
+ }
3075
+ type SyncReactions = Record<string, SyncReactionEntry[]>; // keyed by messageId
3076
+ ```
3077
+
3078
+ **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)`.
3079
+
3080
+ - **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`.**
3081
+
3082
+ ```typescript
3083
+ interface ReactionGroup {
3084
+ emoji: string;
3085
+ userIds: string[]; // all user IDs who reacted with this emoji
3086
+ count: number;
3087
+ users: ReactionUser[]; // resolved displayName + avatarUrl for each userId
3088
+ }
3089
+
3090
+ interface MessageReactionsResponse {
3091
+ messageId: string;
3092
+ reactions: ReactionGroup[];
3093
+ }
3094
+ ```
3095
+
3096
+ - **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.**
3097
+
3098
+ - **No breaking changes** — `SyncReactions` shape change is the only client-visible change; see migration note above.
3099
+
3100
+ ### v1.3.1
3101
+
3102
+ - **New: Bidirectional seq pagination in `conversationSyncBySeq`** — The per-conversation seq-based sync endpoint now supports both directions:
3103
+
3104
+ | Direction | Param | Order | Use case |
3105
+ |---|---|---|---|
3106
+ | Forward (default) | `afterSeq` | oldest → newest | catch-up / sync on reconnect |
3107
+ | Backward | `beforeSeq` | newest → oldest | scroll-back / load older history |
3108
+
3109
+ 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.
3110
+
3111
+ ```typescript
3112
+ // Forward catch-up (default behavior, unchanged)
3113
+ const res = await syncApi.pullConversation(conversationId, { afterSeq: lastKnownSeq });
3114
+
3115
+ // Backward scroll (new)
3116
+ const res = await syncApi.pullConversation(conversationId, { beforeSeq: oldestLocalSeq });
3117
+ if (res.hasMore) {
3118
+ const nextPage = await syncApi.pullConversation(conversationId, { beforeSeq: res.nextSeq! });
3119
+ }
3120
+ ```
3121
+
3122
+ - **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.
3123
+
3124
+ ```typescript
3125
+ // CrossConversationSyncResponse now includes:
3126
+ reactions: SyncReactions; // full current state for messages with reaction activity > since
3127
+ stars: SyncStarEntry[]; // star/unstar records since since; isActive: false = unstarred
3128
+ ```
3129
+
3130
+ **Shape consistency** — stale responses and empty-conversations early returns now also include `reactions: {}` and `stars: []` so the response shape is always uniform.
3131
+
3132
+ - **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.
3133
+
3134
+ - **No breaking changes** — `pullConversation` opts are additive. `CrossConversationSyncResponse` gains two new fields that clients should merge the same way as `ConversationSyncResponse`.
3135
+
3136
+ ### v1.3.0
3137
+
3138
+ - **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.
3139
+
3140
+ `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.
3141
+
3142
+ ```typescript
3143
+ // New fields on existing types:
3144
+ interface Message {
3145
+ seq?: number; // monotonic per-conversation counter
3146
+ // ...
3147
+ }
3148
+
3149
+ interface Conversation {
3150
+ lastSeq?: number; // highest seq in this conversation — use as afterSeq cursor
3151
+ // ...
3152
+ }
3153
+
3154
+ interface MessageAckEvent {
3155
+ seq?: number; // seq assigned to this message by the server
3156
+ // ...
3157
+ }
3158
+
3159
+ interface ConversationUpdatedEvent { // NEW type (v1.3.0)
3160
+ id: string;
3161
+ lastMessage?: Partial<Message> | null;
3162
+ unreadCount?: number;
3163
+ updatedAt?: string;
3164
+ lastSeq?: number; // advance your afterSeq cursor on every message
3165
+ participants?: Participant[];
3166
+ }
3167
+ ```
3168
+
3169
+ - **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.
3170
+
3171
+ - **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.
3172
+
3173
+ - **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.
3174
+
3175
+ - **New exported type: `ConversationUpdatedEvent`.**
3176
+
3177
+ - **No breaking changes** — `seq` and `lastSeq` are additive optional fields. Clients that don't use them are unaffected.
3178
+
3179
+ ### v1.2.9
3180
+
3181
+ - **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.
3182
+
3183
+ ```typescript
3184
+ interface SyncDeliveredReceipt {
3185
+ messageId: string;
3186
+ conversationId: string;
3187
+ userId: string;
3188
+ deliveredAt: string | null;
3189
+ }
3190
+ ```
3191
+
3192
+ **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`.**
3193
+
3194
+ - **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`.**
3195
+
3196
+ - **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.
3197
+
3198
+ - **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.
3199
+
3200
+ - **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.
3201
+
3202
+ - **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.
3203
+
2959
3204
  ### v1.2.8
2960
3205
 
2961
3206
  - **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.