@antzsoft/chat-core 1.4.2 → 1.4.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
@@ -747,7 +747,7 @@ import { messagesApi } from '@antzsoft/chat-core';
747
747
  |---|---|---|
748
748
  | `list` | `(conversationId: string, params?: ListMessagesParams) => Promise<CursorPaginatedResponse<Message>>` | Fetch messages with cursor pagination. |
749
749
  | `get` | `(messageId: string) => Promise<Message>` | Fetch a single message. |
750
- | `send` | `(conversationId: string, payload: SendData) => Promise<Message>` | Send a message via REST (use `socketEmit.sendMessage` for real-time delivery). |
750
+ | `send` | `(conversationId: string, payload: SendData) => Promise<Message>` | Send a message via REST (use `socketEmit.sendMessage` for real-time delivery). Pass `payload.tempId` and reuse the SAME value on retry to make a retry-after-timeout safe — see "Retry safety" below. |
751
751
  | `update` | `(messageId: string, text: string) => Promise<Message>` | Edit message text. |
752
752
  | `delete` | `(messageId: string) => Promise<void>` | Delete a message for everyone (own message within window, or admin). |
753
753
  | `deleteForMe` | `(messageId: string) => Promise<void>` | Hide a message for the current user only — other participants are unaffected. |
@@ -779,7 +779,7 @@ interface SendData {
779
779
  text?: string;
780
780
  attachments?: SendMessageAttachment[];
781
781
  replyTo?: string; // messageId of the message being replied to
782
- tempId?: string; // Client-generated ID for optimistic UI
782
+ tempId?: string; // Client-generated idempotency key see "Retry safety" below. NOT auto-generated if omitted.
783
783
  mentions?: string[]; // Mentioned userIds ('all' for @all); derived from @[name](id) tokens in text
784
784
  }
785
785
 
@@ -813,15 +813,29 @@ await messagesApi.send('conv-abc', {
813
813
  const results = await messagesApi.search({ query: 'deployment', conversationId: 'conv-abc' });
814
814
  ```
815
815
 
816
+ #### Message Send Retry Safety (v1.4.4+)
817
+
818
+ If `messagesApi.send()`/`socketEmit.sendMessage()` times out client-side, you don't know whether the server actually created the message before the response was lost. Retrying blind can create a duplicate. `tempId` fixes this — the server checks `(conversationId, senderId, tempId)` before creating a message, on both the REST send path and the socket path:
819
+
820
+ - Generate **one** `tempId` per send attempt (a real UUID — `generateUUID` from `'@antzsoft/chat-core/internal'`, or `crypto.randomUUID()`).
821
+ - Reuse the **exact same** `tempId` if you retry that same send. Never mint a new one for a retry — only for a genuinely new message.
822
+ - A retry with a matching `tempId` returns the already-created message instead of creating a duplicate. On the socket path this is fully silent to everyone else — the retrying client still gets its `message_ack` (so its optimistic bubble reconciles), but no second `new_message` is broadcast to the room and no second push notification fires, since the original successful attempt already delivered both.
823
+ - Unlike `forward()`, `send()` does **not** auto-generate a `tempId` if you omit it — omitting it, or generating a fresh one on every retry, gets no dedup protection and reproduces the exact pre-1.4.4 behavior.
824
+ - `socketEmit.sendMessage`'s `SendMessagePayload.tempId` was always a **required** field — every client already sends one on every call. This release is what makes the server actually act on it; no wire-format change.
825
+
826
+ **`useChat().retrySendMessage(failedMessageId)` (both UI SDKs)** does all of this correctly for you: it resends the exact original payload (same `tempId`, same already-uploaded attachment `fileId`s — no re-upload) for a message whose `deliveryStatus` is `'failed'`. The built-in `MessageItem` component renders a tappable "Retry" on the failed-delivery indicator and a "Retry" entry in the message action menu, both wired to this — no extra code needed if you're using the prebuilt UI. If you're driving `messagesApi`/`socketEmit` directly (headless usage), you own remembering the `tempId` per attempt yourself.
827
+
816
828
  #### Forward Message (v1.4.2+)
817
829
 
818
830
  Forwarding creates an **independent copy** of a message in each target conversation — never a shared row. Each copy gets its own ID, sequence number, read receipts, and delete lifecycle; deleting one copy never affects another, and none of them affect the original.
819
831
 
820
832
  | Field / method | Type | Description |
821
833
  |---|---|---|
822
- | `messagesApi.forward(messageId, targetConversationIds)` | `Promise<ForwardResult[]>` | Forwards into up to `MAX_FORWARD_TARGETS` (5) conversations in one call — fewer if the source message is highly forwarded, see Limits below. |
834
+ | `messagesApi.forward(messageId, targetConversationIds, attachmentIds?, tempId?)` | `Promise<ForwardResult[]>` | Forwards into up to `MAX_FORWARD_TARGETS` (5) conversations in one call — fewer if the source message is highly forwarded, see Limits below. Pass `attachmentIds` to forward only a subset of the source message's attachments (e.g. one image out of a multi-image message); omit it to forward the whole message unchanged. An ID not on the source message is ignored. `tempId` makes retries safe — see "Retry safety" below; auto-generated if omitted. Uses the socket transport when connected, REST otherwise — see "Transport" below. |
835
+ | `socketEmit.forwardMessage(payload: ForwardMessagePayload)` | `Promise<ForwardAckPayload>` | Lower-level socket-only entry point that `messagesApi.forward()` uses internally when a socket is connected. Most consumers should call `messagesApi.forward()` instead so REST fallback is automatic. |
823
836
  | `Message.forwardedFrom` | `MessageForwardReference \| undefined` | Present on a message created via forwarding. |
824
837
  | `MAX_FORWARD_TARGETS` | `number` | `5` — matches the server's normal-case cap. |
838
+ | `HIGHLY_FORWARDED_DEPTH_THRESHOLD` | `number` | `5` — matches the server's `forwardDepth` value that triggers BOTH the "Forwarded many times" label and the reduced fan-out cap. Use this for the label instead of hardcoding a number. |
825
839
 
826
840
  **Limits:**
827
841
 
@@ -856,13 +870,28 @@ const failed = results.filter(r => !r.success);
856
870
  if (failed.length > 0) {
857
871
  console.warn(`Forward failed for ${failed.length}/${results.length} conversations`, failed);
858
872
  }
873
+
874
+ // Forward only one attachment out of a multi-attachment message (e.g. image 2 of 3)
875
+ await messagesApi.forward(messageId, [convA], [message.content.attachments![1].id]);
859
876
  ```
860
877
 
861
- **Why only one hop of lineage is kept.** `forwardedFrom` always points at the *immediate* message being forwarded, never the ultimate original matching WhatsApp: if you forward a message that was itself already forwarded, the new copy's `originalMessageId`/`originalSenderId` reference that intermediate copy, not the very first message in the chain. This is intentional: it keeps the true original author untraceable after more than one hop (privacy), while `forwardDepth` still accumulates across the whole chain. `forwardDepth` drives two things, both purely about slowing spread, never about blocking it: the "Forwarded many times" badge (client convention: `forwardDepth >= 4`), and server-side, the reduced 1-target fan-out cap once `forwardDepth >= 5` (see Limits above). There is no API to walk a message's full forward history only the immediate parent is ever resolvable, and there is no forward-count ceiling that ever blocks forwarding outright.
878
+ **Where `forwardedFrom` is populated.** Every place a `Message` object is returned`messagesApi.list`/`get`, the `new_message` socket event, `syncApi` (reconnect/resync), and REST list/search/starred/pinned always includes `forwardedFrom` when present. **`conversation_updated`'s `lastMessage` field also includes it** (a conversation-list preview showing a forwarded message can render the "Forwarded" label without a separate message fetch). If `forwardDepth` ever reads as absent/`0` where you expected a value, that's a bug to report, not expected behavior it should never be silently missing on any of these paths.
879
+
880
+ **Why only one hop of lineage is kept.** `forwardedFrom` always points at the *immediate* message being forwarded, never the ultimate original — matching WhatsApp: if you forward a message that was itself already forwarded, the new copy's `originalMessageId`/`originalSenderId` reference that intermediate copy, not the very first message in the chain. This is intentional: it keeps the true original author untraceable after more than one hop (privacy), while `forwardDepth` still accumulates across the whole chain. `forwardDepth` drives two things, both purely about slowing spread, never about blocking it: the "Forwarded many times" badge, and server-side, the reduced 1-target fan-out cap — both gated on the SAME threshold, `HIGHLY_FORWARDED_DEPTH_THRESHOLD` (currently `5`, exported from the package root). Use that constant for the label rather than hardcoding a number, so the two never drift apart again. There is no API to walk a message's full forward history — only the immediate parent is ever resolvable, and there is no forward-count ceiling that ever blocks forwarding outright.
862
881
 
863
882
  **Attachments are never re-uploaded.** A forwarded attachment reuses the same underlying storage object as the source message — only the message row referencing it is new. This is transparent to SDK consumers; `forward()` handles it server-side.
864
883
 
865
- **Rendering:** show a "Forwarded" (or "Forwarded many times" at `forwardDepth >= 4`) label when `message.forwardedFrom` is present — do not render a sender name or content preview for it (unlike `replyTo`), since the message's own `content` already holds what was forwarded. The built-in `MessageItem` component in both `@antzsoft/chat-web-sdk` and `@antzsoft/chat-rn-sdk` already renders this label and exposes a "Forward" action in the message menu — no extra wiring needed if you're using the prebuilt UI.
884
+ **Rendering:** show a "Forwarded" (or "Forwarded many times" at `forwardDepth >= HIGHLY_FORWARDED_DEPTH_THRESHOLD`) label when `message.forwardedFrom` is present — do not render a sender name or content preview for it (unlike `replyTo`), since the message's own `content` already holds what was forwarded. The built-in `MessageItem` component in both `@antzsoft/chat-web-sdk` and `@antzsoft/chat-rn-sdk` already renders this label using the exported constant and exposes a "Forward" action in the message menu — no extra wiring needed if you're using the prebuilt UI.
885
+
886
+ **Transport (v1.4.4+).** `forward()` sends over the `forward_message` socket event (acked via the standard socket.io callback — the same request/response ack pattern as `update_message`/`pin_message`/etc., not a separate broadcast event) whenever a socket is connected, and transparently falls back to REST otherwise — you never call the socket path directly; `messagesApi.forward()` picks it for you. Either way it runs through the exact same server-side `MessagesService.forward()` code path, so broadcast/`conversation_updated`/push-notification behavior is identical regardless of which transport was actually used. There is still no built-in *automatic* retry on timeout — if a call times out client-side, you decide whether/when to retry — but unlike a plain REST call, a client that's connected gets the lower-latency socket path without any code change.
887
+
888
+ **Retry safety (v1.4.4+).** The `tempId` parameter is what makes a retry safe, on either transport:
889
+
890
+ - Generate **one** `tempId` per forward *action* (one user tap on "Forward," covering all its target conversations) — not one per target.
891
+ - Reuse the **exact same** `tempId` if you retry that same action (e.g. the user taps "Forward" again after a partial failure). Never mint a new one for a retry — only when the user starts a genuinely new forward.
892
+ - The server checks `(targetConversationId, senderId, tempId)` — scoped per target, since one forward action can create up to 5 messages, one per target — before creating anything. A retry with a matching `tempId` returns the message that already exists for that target instead of creating a duplicate; targets that failed the first time are retried normally.
893
+ - If you omit `tempId`, one is auto-generated per call — meaning a retry without passing the *same* value back gets **no dedup protection** and may create a duplicate for any target that actually succeeded before the failure was reported. The built-in `ForwardPicker` component in both `@antzsoft/chat-web-sdk` and `@antzsoft/chat-rn-sdk` already does this correctly (one `tempId` per picker session, reused across retries) — no extra wiring needed if you're using the prebuilt UI.
894
+ - Regular message sends (`socketEmit.sendMessage` / `messagesApi.send()`) get this same protection — see "Retry safety" above.
866
895
 
867
896
  #### Jump to first unread message
868
897