diff --git a/docs.json b/docs.json index 96952f214..477070d3f 100644 --- a/docs.json +++ b/docs.json @@ -3488,6 +3488,19 @@ "sdk/react-native/reactions" ] }, + { + "group": "Offline Support", + "pages": [ + "sdk/react-native/offline-overview", + "sdk/react-native/offline-enable", + "sdk/react-native/offline-cached-data", + "sdk/react-native/offline-send", + "sdk/react-native/offline-sync", + "sdk/react-native/offline-media-caching", + "sdk/react-native/offline-encryption", + "sdk/react-native/offline-reference" + ] + }, "sdk/react-native/calling-overview", { "group": "Users", diff --git a/sdk/react-native/additional-message-filtering.mdx b/sdk/react-native/additional-message-filtering.mdx index bfd01de44..2ff6bc7fb 100644 --- a/sdk/react-native/additional-message-filtering.mdx +++ b/sdk/react-native/additional-message-filtering.mdx @@ -1552,6 +1552,26 @@ Saves are private to the logged-in user. Because the rows come from different co +## Offline Behaviour + +With [offline support](/sdk/react-native/offline-cached-data) enabled, a `MessagesRequest` can answer from the on-device cache first and then from the server. Pass a `MessageFetchListener` to `fetchPrevious()` or `fetchNext()` to receive `onCachedMessages` (even when empty) and then `onServerMessages`. Offline, the fetch ends with `onError` with code `FAILED_TO_FETCH` after the cached page. + +The cache answers a page **only when every filter on the request is locally computable**. A single server-only filter makes the cached page empty with no error, while the server phase still answers in full. + +| Builder method | Offline behaviour | +| --- | --- | +| `setSearchKeyword()` | Server only — cached page is empty | +| `hasLinks()` | Server only — cached page is empty | +| `updatesOnly()` | Server only — cached page is empty | +| `setInteractionGoalCompletedOnly()` | Server only — cached page is empty | +| `setPinnedOnly()` | Pinned store | +| `setSavedOnly()` | Saved store | +| All other filters in this page | Cached | + +`setPinnedOnly()` and `setSavedOnly()` are served from separate pinned and saved stores, not the message cache, so the other filters on this page do not apply to a pinned or saved read. + +`setMessageId()`, `setTimestamp()`, `setUpdatedAfter()`, `setLimit()`, `setUID()` and `setGUID()` are scope and pagination inputs, not filters, and do not change what the cache can answer. Setting both `setUID()` and `setGUID()` together on a non-thread read (one sender's messages in a group) is answered by the server only. + ## Next Steps diff --git a/sdk/react-native/ai-moderation.mdx b/sdk/react-native/ai-moderation.mdx index 4bd78f344..871b24dcf 100644 --- a/sdk/react-native/ai-moderation.mdx +++ b/sdk/react-native/ai-moderation.mdx @@ -66,6 +66,7 @@ sequenceDiagram participant Moderation App->>SDK: sendMessage() + SDK-->>App: onMessagePending (offline support on, with a listener) SDK->>CometChat: Send Message CometChat->>Moderation: Process Message CometChat-->>SDK: Message (status: PENDING) @@ -78,7 +79,7 @@ sequenceDiagram | Step | Description | |------|-------------| | 1. Send Message | App sends a text, image, or video message | -| 2. Pending Status | Message is sent with `PENDING` moderation status | +| 2. Pending Status | Message is sent with `PENDING` moderation status (`getModerationStatus()`). This is separate from the send status: with offline support on and a `MessageSentListener` passed, `onMessagePending` fires first, before the message reaches the server. | | 3. AI Processing | Moderation service analyzes the content | | 4. Result Event | `onMessageModerated` event fires with final status | @@ -111,6 +112,10 @@ The `getModerationStatus()` method returns one of the following values: When you send a text, image, or video message, check the initial moderation status: + +With offline support enabled, pass a `MessageSentListener` to `sendMessage()` to queue the message while offline. `onMessagePending` fires first. Offline, `onError` can fire once as a notice that the message is queued, not failed: check the `reason` argument (`null` means queued, a value means a real failure). The moderation status applies once the message reaches the server. See [Send While Offline](/sdk/react-native/offline-send). + + ```typescript diff --git a/sdk/react-native/block-users.mdx b/sdk/react-native/block-users.mdx index 087285cd9..8d0be8327 100644 --- a/sdk/react-native/block-users.mdx +++ b/sdk/react-native/block-users.mdx @@ -170,6 +170,7 @@ let blockedUsersRequest = new CometChat.BlockedUsersRequestBuilder() ### Set Search Keyword +With offline support on, this filter is answered by the server only; the cached page is empty. Filters blocked users by a search string. @@ -279,6 +280,16 @@ Relevant fields to access on returned users: | blockedByMe | `getBlockedByMe()` | `boolean` | Whether the logged-in user has blocked this user | | hasBlockedMe | `getHasBlockedMe()` | `boolean` | Whether this user has blocked the logged-in user | +## Offline Behaviour + +With [offline support](/sdk/react-native/offline-cached-data) enabled, passing a `UserFetchListener` to `BlockedUsersRequest.fetchNext()` calls `onCachedUsers` first from the SDK's own blocked-users list. The SDK refreshes that list when the user logs in and after a reconnect sync once the last refresh is more than 12 hours old, and it updates the list when you call `blockUsers()` or `unblockUsers()`. Then `onServerUsers` delivers the live page to reconcile by `uid`; the server page is not saved to the device. The cached page is empty when no users are blocked or when `setSearchKeyword` is set. Offline, the read ends with `onError` (`FAILED_TO_FETCH`) after the cached page, and the Promise rejects. See [Show Cached Data](/sdk/react-native/offline-cached-data). + +| Builder method | Offline | +|---|---| +| `setLimit` | Cached | +| `setDirection` | Cached. When no direction is set, the cached page lists only the users you blocked. | +| `setSearchKeyword` | Server only, cached page is empty | + --- ## Next Steps diff --git a/sdk/react-native/campaigns.mdx b/sdk/react-native/campaigns.mdx index d31eb9bca..84affe3cc 100644 --- a/sdk/react-native/campaigns.mdx +++ b/sdk/react-native/campaigns.mdx @@ -144,6 +144,10 @@ Call `fetchNext()` repeatedly for pagination. When the server has no more items, | `getReceiverType()` | string | Receiver type | | `getIsRead()` | boolean | Whether the item has been read | +## Offline Behaviour + +With offline support enabled, pass a `FeedItemFetchListener` (alias `NotificationFeedFetchListener`) to `fetchNext()`. It first delivers `onCachedNotificationFeedItems` from the device, then `onServerNotificationFeedItems` with the live page to reconcile by item id. Offline, the read ends with `onError` (code `FAILED_TO_FETCH`) after the cached page. See [Show Cached Data](/sdk/react-native/offline-cached-data). + --- ## Retrieve Notification Categories diff --git a/sdk/react-native/connection-status.mdx b/sdk/react-native/connection-status.mdx index a8dadf681..31a57aae7 100644 --- a/sdk/react-native/connection-status.mdx +++ b/sdk/react-native/connection-status.mdx @@ -39,6 +39,10 @@ The CometChat SDK maintains a WebSocket connection to CometChat servers for real When the connection drops, the SDK automatically attempts to reconnect, cycling through `disconnected` → `connecting` → `connected`. + +With offline support enabled, your app keeps working without a connection: cached chats are shown instantly, and messages you send with a `MessageSentListener` are queued and delivered automatically on reconnect. See [Show Cached Data](/sdk/react-native/offline-cached-data) and [Send While Offline](/sdk/react-native/offline-send). + + ## Connection States | Value | Callback | Description | diff --git a/sdk/react-native/delete-group.mdx b/sdk/react-native/delete-group.mdx index b29d074ab..af764a07c 100644 --- a/sdk/react-native/delete-group.mdx +++ b/sdk/react-native/delete-group.mdx @@ -36,6 +36,10 @@ This operation is irreversible. Deleted groups and their messages cannot be reco ## Delete a Group + +Deleting a group needs a connection. With offline support enabled, `deleteGroup()` is **not** queued for later. After it succeeds, the group's conversation and cached messages leave the on-device cache at the next sync, once the server no longer lists the group. Any messages still queued to it are then reported as failed with reason `DISCARDED`. See [Send While Offline](/sdk/react-native/offline-send). + + Use `deleteGroup()` with the group's GUID. diff --git a/sdk/react-native/delete-message.mdx b/sdk/react-native/delete-message.mdx index feef1ab6d..45f4fc527 100644 --- a/sdk/react-native/delete-message.mdx +++ b/sdk/react-native/delete-message.mdx @@ -52,6 +52,10 @@ Deleting a message is straightforward. Receiving delete events has two parts: Use `deleteMessage()` with the message ID. + +Deleting a message needs a connection. With offline support enabled, `deleteMessage()` is not queued for later. After the Promise resolves, the deletion is applied to the message in the on-device cache if it is already cached; it never adds a new message. See [Send While Offline](/sdk/react-native/offline-send). + + ```typescript diff --git a/sdk/react-native/edit-message.mdx b/sdk/react-native/edit-message.mdx index ad53d45fe..98b83c2e3 100644 --- a/sdk/react-native/edit-message.mdx +++ b/sdk/react-native/edit-message.mdx @@ -47,6 +47,10 @@ Editing a message is straightforward. Receiving edit events has two parts: Use `editMessage()` with a [`TextMessage`](/sdk/reference/messages#textmessage) or [`CustomMessage`](/sdk/reference/messages#custommessage) object. Set the message ID using `setId()`. + +Editing a message needs a connection. With offline support enabled, `editMessage()` is not queued for later. After the Promise resolves, the edit is applied to the message in the on-device cache if it is already cached; it never adds a new message. See [Send While Offline](/sdk/react-native/offline-send). + + ### Add/Update Tags Use `setTags()` to update tags when editing. New tags replace existing ones. diff --git a/sdk/react-native/group-kick-ban-members.mdx b/sdk/react-native/group-kick-ban-members.mdx index 552810327..1216eb158 100644 --- a/sdk/react-native/group-kick-ban-members.mdx +++ b/sdk/react-native/group-kick-ban-members.mdx @@ -225,6 +225,8 @@ let bannedGroupMembersRequest = new CometChat.BannedMembersRequestBuilder(GUID) Filters banned members by a search string. +With offline support on, this filter is answered by the server only; the cached page is empty. + ```typescript @@ -254,6 +256,39 @@ let bannedGroupMembersRequest = new CometChat.BannedMembersRequestBuilder(GUID) +### Set Scopes + +Filters banned members by one or more scopes (`admin`, `moderator`, `participant`). + + + +```typescript +let GUID: string = "GUID"; +let limit: number = 30; +let scopes: Array = ["admin", "moderator"]; +let bannedGroupMembersRequest: CometChat.BannedMembersRequest = new CometChat.BannedMembersRequestBuilder(GUID) + .setLimit(limit) + .setScopes(scopes) + .build(); +``` + + + + +```javascript +let GUID = "GUID"; +let limit = 30; +let scopes = ["admin", "moderator"]; +let bannedGroupMembersRequest = new CometChat.BannedMembersRequestBuilder(GUID) + .setLimit(limit) + .setScopes(scopes) + .build(); +``` + + + + + Once configured, call `build()` to create the request, then `fetchNext()` to retrieve banned members. @@ -297,6 +332,16 @@ bannedGroupMembersRequest.fetchNext().then( The `fetchNext()` method returns an array of [`GroupMember`](/sdk/reference/entities#groupmember) objects representing the banned members of the group. +## Offline Behaviour + +With [offline support](/sdk/react-native/offline-cached-data) enabled, passing a `BannedGroupMemberFetchListener` to `BannedMembersRequest.fetchNext()` calls `onCachedBannedGroupMembers` first with the pages the server last returned for this exact request, replayed from the device (empty when nothing was recorded). Then `onServerBannedGroupMembers` delivers the live page to reconcile by `uid`. Offline, the read ends with `onError` (`FAILED_TO_FETCH`) after the cached page, and the Promise rejects. A request that uses a server-only filter records nothing, so its cached page is always empty. See [Show Cached Data](/sdk/react-native/offline-cached-data). + +| Builder method | Offline | +|---|---| +| `setLimit` | Cached | +| `setScopes` | Cached | +| `setSearchKeyword` | Server only, cached page is empty | + ## Real-Time Kick/Ban/Unban Events Implement these `GroupListener` methods to receive real-time notifications: diff --git a/sdk/react-native/leave-group.mdx b/sdk/react-native/leave-group.mdx index c006708ea..57719903b 100644 --- a/sdk/react-native/leave-group.mdx +++ b/sdk/react-native/leave-group.mdx @@ -39,6 +39,10 @@ Group owners cannot leave without first transferring ownership to another member ## Leave a Group + +With offline support enabled, leaving a group clears that group's conversation and cached messages from the on-device cache. Any messages still queued to it are dropped and reported as failed with reason `DISCARDED`. See [Send While Offline](/sdk/react-native/offline-send). + + Use `leaveGroup()` to leave a group. diff --git a/sdk/react-native/mentions.mdx b/sdk/react-native/mentions.mdx index 63af1cad6..5934ea6c1 100644 --- a/sdk/react-native/mentions.mdx +++ b/sdk/react-native/mentions.mdx @@ -137,6 +137,12 @@ You can mention user in text message and media messages captions + + +With [offline support](/sdk/react-native/offline-send) enabled, a mention message is queued only if you pass a `MessageSentListener` to `sendMessage()`. When the device is offline, `onError` then also fires to tell you the message is queued: check `error.errorParams.queued`, or that `reason` is `null`. `getMessageStatus()` on the message is `PENDING` while it is queued and `FAILED` if it failed. Without a listener the send goes straight to the server and rejects when offline. + + + ## Mentioned Messages By default, the SDK will fetch all the messages irrespective of the fact that the logged-in user is mentioned or not in the message. The SDK has other optional filters such as tags and blocked relationships. diff --git a/sdk/react-native/offline-cached-data.mdx b/sdk/react-native/offline-cached-data.mdx new file mode 100644 index 000000000..991a4dc5d --- /dev/null +++ b/sdk/react-native/offline-cached-data.mdx @@ -0,0 +1,268 @@ +--- +title: "Show Cached Data" +sidebarTitle: "Show Cached Data" +description: "Render cached messages, conversations, and lists instantly and reconcile them against the server with the CometChat React Native Chat SDK." +--- + + + +| Field | Value | +| --- | --- | +| Applies to | `fetchNext()` / `fetchPrevious()` on every request class, plus `getConversation()` and `getMessageDetails()` | +| How to opt in | Pass a listener. A read with no listener gets the server result only. | +| Order | `onCached…` (instant, may be empty) → `onServer…` (replaces the same slice) | +| Offline | After the cached page the read ends with `onError` (`FAILED_TO_FETCH`) and the Promise rejects | +| Prerequisites | [Offline support enabled](/sdk/react-native/offline-enable) | + +```typescript +const request = new CometChat.MessagesRequestBuilder().setUID("UID").setLimit(30).build(); + +request.fetchPrevious({ + onCachedMessages: (messages) => { /* Phase 1: local cache. Render immediately. */ }, + onServerMessages: (messages) => { /* Phase 2: server. Reconcile, don't append. */ }, + onError: (error) => { /* Offline ends here, after the cached page. */ }, +}); + +CometChat.getConversation("UID", CometChat.RECEIVER_TYPE.USER, conversationDetailsListener); +CometChat.getMessageDetails(messageId, messageDetailsListener); +``` + + + +With offline support enabled, every list and lookup the SDK already offers can answer from the on-device cache first and then from the server. Your UI paints instantly from the cache, then reconciles when the server replies. This page describes that contract, the reads it applies to, and how to merge the two phases. + +## The two-phase contract + +Pass a listener to a read and it fires **up to two** callbacks, always in this order: + + + +The SDK delivers a page from the local cache **first**. It fires **even when the page is empty** — an empty cached page is indistinguishable from a cache miss, by design. Render it right away. + + +After a successful server response, the SDK delivers the server's copy for the **same slice** and writes it through to the cache before the callback. Reconcile your displayed list against it. (Blocked users are the exception: a read of the blocked-users list delivers the server page but does not write it to the cache. The SDK keeps that list current with its own background refresh instead.) + + + +Three rules follow from this: + +- **The Promise is unchanged.** The read still returns a Promise that settles on the **server** result. The cached page never settles it, and a read with no listener receives no cached phase. Consume the final result from one channel — the Promise or the `onServer…` callback. +- **Offline, the read ends with an error.** After the cached page, a read that gets no answer because the device is offline settles with `onError` and rejects the Promise with the SDK's not-connected error, code `FAILED_TO_FETCH`. A real server error reaches `onError` and the Promise unchanged. The call always settles. Wait for each fetch to finish before you start the next one on the same request object: an overlapping call gets no cached page and behaves as it did without offline support. For messages, threads, reactions and the notification feed it fails with a request-in-progress error. For users, groups, members and conversations it returns an empty list. +- **The server phase never fires offline.** It fires only on a genuine server response. Never treat a cached page as "end of history"; an empty cached page only means the end of the *cached* window. + + +With offline support **off**, these listeners behave like a plain read: the cached phase never fires, and the server result arrives in `onServer…` alongside the Promise. Nothing changes until you enable offline ([Enable Offline Support](/sdk/react-native/offline-enable)). Requires SDK **4.2.0**. + + +## Read messages + +`MessagesRequest` supports both directions. `fetchPrevious` pages toward older messages; `fetchNext` pages toward newer ones. Both accept a `MessageFetchListener`. + + + +```typescript +const request: CometChat.MessagesRequest = new CometChat.MessagesRequestBuilder() + .setUID("UID") + .setLimit(30) + .build(); + +request.fetchPrevious({ + onCachedMessages: (messages: CometChat.BaseMessage[]) => { + // Render the cached page immediately. + }, + onServerMessages: (messages: CometChat.BaseMessage[]) => { + // Reconcile this slice against the cached page, keyed by message id. + }, + onError: (error: CometChat.CometChatException) => { + console.log("Fetch failed:", error.code); + }, +}); +``` + + +```javascript +const request = new CometChat.MessagesRequestBuilder() + .setUID("UID") + .setLimit(30) + .build(); + +request.fetchPrevious({ + onCachedMessages: (messages) => { + // Render the cached page immediately. + }, + onServerMessages: (messages) => { + // Reconcile this slice against the cached page, keyed by message id. + }, + onError: (error) => { + console.log("Fetch failed:", error.code); + }, +}); +``` + + + +## Read conversations + +`ConversationsRequest` pages in one direction with `fetchNext`, accepting a `ConversationFetchListener`. + + + +```typescript +const request: CometChat.ConversationsRequest = new CometChat.ConversationsRequestBuilder() + .setLimit(30) + .build(); + +request.fetchNext({ + onCachedConversations: (conversations: CometChat.Conversation[]) => { + // Render the cached list immediately. + }, + onServerConversations: (conversations: CometChat.Conversation[]) => { + // Reconcile, keyed by conversation id. + }, + onError: (error: CometChat.CometChatException) => { + console.log("Fetch failed:", error.code); + }, +}); +``` + + +```javascript +const request = new CometChat.ConversationsRequestBuilder() + .setLimit(30) + .build(); + +request.fetchNext({ + onCachedConversations: (conversations) => { + // Render the cached list immediately. + }, + onServerConversations: (conversations) => { + // Reconcile, keyed by conversation id. + }, + onError: (error) => { + console.log("Fetch failed:", error.code); + }, +}); +``` + + + +## Read the other lists + +Every remaining list read accepts the same optional listener. Each listener has cached/server callbacks of the matching type plus `onError`. + +| Request | Fetch method(s) | Listener | Cached / server callback | +|---|---|---|---| +| `MessagesRequest` | `fetchPrevious`, `fetchNext` | `MessageFetchListener` | `onCachedMessages` / `onServerMessages` | +| `ConversationsRequest` | `fetchNext` | `ConversationFetchListener` | `onCachedConversations` / `onServerConversations` | +| `ThreadsRequest` | `fetchNext` | `ThreadFetchListener` (alias `ThreadsFetchListener`) | `onCachedThreads` / `onServerThreads` | +| `UsersRequest` | `fetchNext` | `UserFetchListener` | `onCachedUsers` / `onServerUsers` | +| `BlockedUsersRequest` | `fetchNext` | `UserFetchListener` | `onCachedUsers` / `onServerUsers` | +| `GroupsRequest` | `fetchNext` | `GroupFetchListener` | `onCachedGroups` / `onServerGroups` | +| `GroupMembersRequest` | `fetchNext` | `GroupMemberFetchListener` | `onCachedGroupMembers` / `onServerGroupMembers` | +| `BannedMembersRequest` | `fetchNext` | `BannedGroupMemberFetchListener` | `onCachedBannedGroupMembers` / `onServerBannedGroupMembers` | +| `ReactionsRequest` | `fetchPrevious`, `fetchNext` | `ReactionFetchListener` | `onCachedReactions` / `onServerReactions` | +| `NotificationFeedRequest` | `fetchNext` | `FeedItemFetchListener` (alias `NotificationFeedFetchListener`) | `onCachedNotificationFeedItems` / `onServerNotificationFeedItems` | + +The call shape is identical to the conversations example above — swap the request, the listener, and the callback names. + + + +```typescript +const request: CometChat.UsersRequest = new CometChat.UsersRequestBuilder() + .setLimit(30) + .build(); + +request.fetchNext({ + onCachedUsers: (users: CometChat.User[]) => { /* render */ }, + onServerUsers: (users: CometChat.User[]) => { /* reconcile, keyed by uid */ }, + onError: (error: CometChat.CometChatException) => console.log(error.code), +}); +``` + + +```javascript +const request = new CometChat.UsersRequestBuilder() + .setLimit(30) + .build(); + +request.fetchNext({ + onCachedUsers: (users) => { /* render */ }, + onServerUsers: (users) => { /* reconcile, keyed by uid */ }, + onError: (error) => console.log(error.code), +}); +``` + + + + +For `ReactionsRequest`, use one request object per direction. Each direction is cached under its own key, but a request object keeps a single page counter, so one object used in both directions replays the wrong pages. Users, groups, members, banned members, reactions, and the notification feed are served from a per-request snapshot rather than a shared store; the number of snapshots and how long they stay valid are set by `maxSnapshotQueries` and `snapshotTtlDays` in the [Configuration & API Reference](/sdk/react-native/offline-reference). + + +## Read a single item + +`getConversation` and `getMessageDetails` each accept an optional listener as a last argument. Unlike the list reads, the cached callback fires **only when the item is cached** — never with an empty or undefined object. A cache miss goes straight to the server phase online, or straight to `onError` offline. The Promise still settles on the server result. + + + +```typescript +CometChat.getConversation("UID", CometChat.RECEIVER_TYPE.USER, { + onCachedConversation: (conversation?: CometChat.Conversation) => { + // Fires only on a cache hit. + }, + onServerConversation: (conversation: CometChat.Conversation) => { + // Server copy, written through before delivery. + }, + onError: (error: CometChat.CometChatException) => { + console.log("Lookup failed:", error.code); + }, +}); + +CometChat.getMessageDetails(messageId, { + onCachedMessage: (message?: CometChat.BaseMessage) => { /* cache hit only */ }, + onServerMessage: (message: CometChat.BaseMessage) => { /* server copy */ }, + onError: (error: CometChat.CometChatException) => console.log(error.code), +}); +``` + + +```javascript +CometChat.getConversation("UID", CometChat.RECEIVER_TYPE.USER, { + onCachedConversation: (conversation) => { + // Fires only on a cache hit. + }, + onServerConversation: (conversation) => { + // Server copy, written through before delivery. + }, + onError: (error) => { + console.log("Lookup failed:", error.code); + }, +}); + +CometChat.getMessageDetails(messageId, { + onCachedMessage: (message) => { /* cache hit only */ }, + onServerMessage: (message) => { /* server copy */ }, + onError: (error) => console.log(error.code), +}); +``` + + + +The single-conversation listener is `ConversationDetailsListener`; the single-message listener is `MessageDetailsListener`. + +## Merge the two phases + +The server phase **replaces the same slice** — it is a reconcile, not a second batch. Render the cached page straight away, then on the server phase diff it into your displayed list keyed by the item's stable id (message id, conversation id, uid, guid). Do not append the server page to the cached one. + + +Key your list by id so the two phases merge cleanly: update items that changed, add new ones, and drop cached items the server slice omits. To page further back, issue the next `fetchPrevious()` / `fetchNext()` rather than treating the server phase as more pages. + + +## When the cached page is empty + +The cache answers a read **only if every filter on it is locally computable**. A single server-only filter makes the entire cached phase return an empty list — with no error and no warning — while the server phase still answers in full. The guiding rule is *better empty than wrong*. + +The most common server-only filter is `searchKeyword`, which blanks the cached phase on any request that carries a search keyword (threads, reactions and the notification feed have none). Other examples on `MessagesRequest` are `hasLinks`, `updatesOnly`, `interactionGoalCompletedOnly`, and setting both `UID` and `GUID` outside a thread (one sender's messages in a group). Also, `hideMessagesFromBlockedUsers` returns an empty cached page until the blocked-users list has been fetched from the server once. Presence filters such as `status` on the user and member lists also fall back to the server. + + +An offline-critical screen must use only cache-supported filters — one innocuous builder flag can silently disable the cached phase for a whole screen. The full per-builder matrix lives in the [Configuration & API Reference](/sdk/react-native/offline-reference). + diff --git a/sdk/react-native/offline-enable.mdx b/sdk/react-native/offline-enable.mdx new file mode 100644 index 000000000..3f1c0fb18 --- /dev/null +++ b/sdk/react-native/offline-enable.mdx @@ -0,0 +1,168 @@ +--- +title: "Enable Offline Support" +sidebarTitle: "Enable Offline Support" +description: "Turn on local caching and the durable send outbox, and verify offline support is active with the CometChat React Native Chat SDK." +--- + + + +| Field | Value | +| --- | --- | +| Package | `@cometchat/chat-sdk-react-native` (4.2.0 or later) | +| Config class | `CometChat.OfflineConfig` — `new CometChat.OfflineConfig({ enabled: true })` | +| Attach to | `new CometChat.AppSettingsBuilder().setOfflineConfig(config)` (or the `.offline(true)` shorthand) | +| Install (peers) | `@op-engineering/op-sqlite` 9.0.0+ (required), `react-native-fs`, `react-native-keychain` | +| Verify | `CometChat.isOfflineEnabled()` → `boolean` | +| Constraints | Off by default. Not available in Expo Go — use a development build or `expo prebuild`. | + +```typescript +const offlineConfig = new CometChat.OfflineConfig({ enabled: true }); + +const appSettings = new CometChat.AppSettingsBuilder() + .setRegion("REGION") + .setOfflineConfig(offlineConfig) // explicit config; its `enabled` wins + // .offline(true) // shorthand: enable with all defaults + .build(); + +await CometChat.init("APP_ID", appSettings); + +const active: boolean = CometChat.isOfflineEnabled(); +``` + + + +Offline support is **off by default**. You turn it on once, at initialization, by attaching an `OfflineConfig` to your `AppSettings`. Until you do, the SDK behaves exactly as before: no local store is opened and no native module is loaded. This page covers the minimal setup; for every tunable option see the [Configuration & API Reference](/sdk/react-native/offline-reference). + + + + +Offline support requires CometChat React Native Chat SDK **4.2.0** or later. + +The local database is provided by a native module that you install in your app. It is an optional peer dependency of the SDK, so installing the SDK never requires it: + +| Package | Needed for | +| --- | --- | +| `@op-engineering/op-sqlite` (9.0.0 or later) | The local store. **Required for offline.** Without it the SDK runs online-only. | +| `react-native-fs` | Media caching and durable media sends. | +| `react-native-keychain` | Storing the encryption key, only with [encryption](/sdk/react-native/offline-encryption). | + +```bash +npm install @op-engineering/op-sqlite react-native-fs +``` + + +`@op-engineering/op-sqlite` is a native module. In Expo, use a development build or `expo prebuild` — offline does not run in Expo Go (the SDK then runs online-only). Pick an `op-sqlite` release that supports your React Native version. + + +If you are new to the SDK, complete the [standard setup](/sdk/react-native/setup-sdk) first; the steps below only add offline configuration to your existing `init`. + + + +Construct an `OfflineConfig` and set `enabled: true`. Every other option has a sensible default, so set only what you need. Invalid values are normalised to the default; they are never rejected. + + + +```typescript +const offlineConfig: CometChat.OfflineConfig = new CometChat.OfflineConfig({ + enabled: true, +}); +``` + + +```javascript +const offlineConfig = new CometChat.OfflineConfig({ + enabled: true, +}); +``` + + + + + +Pass the config to `AppSettingsBuilder.setOfflineConfig(...)`. To enable with all defaults instead of building a config, use the `offline(boolean)` shorthand. + + + +```typescript +const appSettings: CometChat.AppSettings = new CometChat.AppSettingsBuilder() + .setRegion("REGION") + .setOfflineConfig(offlineConfig) // explicit config + // .offline(true) // shorthand: enable with defaults + .build(); +``` + + +```javascript +const appSettings = new CometChat.AppSettingsBuilder() + .setRegion("REGION") + .setOfflineConfig(offlineConfig) // explicit config + // .offline(true) // shorthand: enable with defaults + .build(); +``` + + + + +If you call both `setOfflineConfig(...)` and `offline(boolean)`, the explicit `OfflineConfig` wins — its `enabled` value is authoritative. + + + + +Pass the settings to `CometChat.init(...)` as you do today. No other call changes. + + + +```typescript +CometChat.init("APP_ID", appSettings).then( + () => { + console.log("Initialization completed successfully"); + }, + (error: CometChat.CometChatException) => { + console.log("Initialization failed with error:", error); + } +); +``` + + +```javascript +CometChat.init("APP_ID", appSettings).then( + () => { + console.log("Initialization completed successfully"); + }, + (error) => { + console.log("Initialization failed with error:", error); + } +); +``` + + + + +On a relaunch with a saved login, `init` also starts offline support, and waits for it for up to 8 seconds. Add your [cache-reset](/sdk/react-native/offline-sync#when-the-cache-resets) and [outbox](/sdk/react-native/offline-send#observe-the-queue-app-wide) listeners straight after `await CometChat.init(...)` resolves. Anything raised during startup is delivered just after `init` resolves. + + + + +Call `CometChat.isOfflineEnabled()`. It returns `true` only while offline support is actually active for the logged-in user. It is `false` before anyone logs in (even when you enabled it in `AppSettings`), after logout or session expiry, and when the local store or the encryption key store cannot open. Read it at the moment of each decision and keep no copy of the result. + + + +```typescript +const active: boolean = CometChat.isOfflineEnabled(); +console.log("Offline support active:", active); +``` + + +```javascript +const active = CometChat.isOfflineEnabled(); +console.log("Offline support active:", active); +``` + + + + + + +## Next steps + +Offline support is now active for the logged-in user. Continue with [Show Cached Data](/sdk/react-native/offline-cached-data) to adopt the two-phase listeners, and [Send While Offline](/sdk/react-native/offline-send) for the durable outbox. Tune caps, TTLs, media, and encryption from the [Configuration & API Reference](/sdk/react-native/offline-reference). diff --git a/sdk/react-native/offline-encryption.mdx b/sdk/react-native/offline-encryption.mdx new file mode 100644 index 000000000..42ef2bf3f --- /dev/null +++ b/sdk/react-native/offline-encryption.mdx @@ -0,0 +1,187 @@ +--- +title: "Encrypt the Cache" +sidebarTitle: "Encrypt the Cache" +description: "Encrypt the offline message cache at rest with SQLCipher, choose what happens when encryption cannot run, and inspect the live encryption status with the CometChat React Native Chat SDK." +--- + + + +| Field | Value | +| --- | --- | +| 1. SQLCipher in your app | Add `{"op-sqlite": {"sqlcipher": true}}` to **your app's** `package.json`, then rebuild | +| 2. A key store | Install `react-native-keychain`, or call `CometChat.setOfflineKeyVault(vault)` before `init` | +| 3. Turn it on | `new CometChat.OfflineConfig({ enabled: true, encryption: true })` | +| Failure behavior | `encryptionFailureBehavior`: `ONLINE_ONLY` (default) or `PLAINTEXT_FALLBACK` | +| Inspect | `CometChat.getOfflineEncryptionStatus()` → `{ enabled, atRest, keyStorage, cipherAvailable, cipher, leakage }` | + +```typescript +// package.json (your app) — then rebuild +// "op-sqlite": { "sqlcipher": true } + +const offlineConfig = new CometChat.OfflineConfig({ + enabled: true, + encryption: true, + encryptionFailureBehavior: CometChat.EncryptionFailureBehavior.ONLINE_ONLY, // default; never writes plaintext +}); + +const status = CometChat.getOfflineEncryptionStatus(); +status.enabled; // what was configured +status.atRest; // "SQLCIPHER" or "NONE" +status.keyStorage; // "KEYSTORE", "SOFTWARE" or "NONE" +status.cipherAvailable; // is SQLCipher present in this build? +status.cipher; // engine cipher_version once proven, else null +status.leakage; // what is never covered (media files on disk) +``` + + + +Offline support keeps the logged-in user's messages in a local database on the device. Turn encryption on to store that database **encrypted at rest** using [SQLCipher](https://www.zetetic.net/sqlcipher/). Encryption is **opt-in** and off by default; nothing changes until you set `encryption: true`. The minimum SDK version is **4.2.0**. + + +Encryption covers the **message database only**. Downloaded media files (photos, voice notes, documents) stay as ordinary unencrypted files on disk, because their local paths are handed to image and video loaders. See [What gets encrypted](#what-gets-encrypted). This is not end-to-end encryption: CometChat's servers still see message content. + + +## Prepare your app + +The SDK does **not** bundle the cipher engine or a key store, so apps that do not use encryption pay nothing for them. If you enable encryption you must provide both, on your side. + + + +The local database is provided by `@op-engineering/op-sqlite`. Add its SQLCipher flag to **your app's** `package.json` and rebuild the native project: + +```json package.json +{ + "op-sqlite": { + "sqlcipher": true + } +} +``` + + +Install `react-native-keychain`; the SDK detects it automatically. Alternatively, supply your own store with `CometChat.setOfflineKeyVault(vault)` before `init`. A vault implements `get(service)`, `set(service, hexKey)`, `delete(service)` and `kind()`, where `kind()` reports `"KEYSTORE"`, `"SOFTWARE"`, or `"NONE"`. Pass `null` to return to auto-detection. + + + + +If either is missing, the cache **cannot run encrypted**. What happens then is decided by [`encryptionFailureBehavior`](#choose-what-happens-when-encryption-cannot-run) — by default the session runs online-only (no local cache). The SDK never creates a database that is labelled encrypted but is not. + + +## Turn on encryption + +Set `encryption: true` on the `OfflineConfig` you pass to the SDK during initialization (see [Enable Offline Support](/sdk/react-native/offline-enable)). Like the other options, it is read once at init and fixed for the session. + + + +```typescript +const offlineConfig: CometChat.OfflineConfig = new CometChat.OfflineConfig({ + enabled: true, + encryption: true, + encryptionFailureBehavior: CometChat.EncryptionFailureBehavior.ONLINE_ONLY, +}); +``` + + +```javascript +const offlineConfig = new CometChat.OfflineConfig({ + enabled: true, + encryption: true, + encryptionFailureBehavior: CometChat.EncryptionFailureBehavior.ONLINE_ONLY, +}); +``` + + + +## Choose what happens when encryption cannot run + +`encryptionFailureBehavior` decides what the cache does when encryption **cannot be applied at all** on this build or device — for example SQLCipher is not compiled into the app, or there is no secure key store. It governs only that case: a transient failure still retries without writing plaintext, and a lost or wrong key self-heals by wiping and rebuilding the cache **encrypted**. + +| Value | Behavior | Writes plaintext? | +|---|---|---| +| `ONLINE_ONLY` *(default)* | Do not open a local cache at all — the session runs online-only. Makes a setup mistake visible: offline simply does not work until the missing piece is added. | No | +| `PLAINTEXT_FALLBACK` | Open an **unencrypted** cache so offline keeps working. | Yes | + + +`PLAINTEXT_FALLBACK` means cached message content may be stored **unencrypted** on disk whenever encryption cannot run. This availability-over-guarantee trade is wrong for compliance use — keep the default `ONLINE_ONLY` if cached content must never be written in the clear. + + +## Key storage + +You do not generate or manage the encryption key. The SDK generates a random 32-byte key, one per app ID and logged-in user (UID), and keeps it in the platform keychain through `react-native-keychain`, or in the vault you supplied. The key never leaves the device and is never returned by any API. If the key is lost or the stored database no longer matches it, the cache resets and rebuilds — see [Cache resets](#cache-resets-on-key-loss). + +## Check encryption status + +`CometChat.getOfflineEncryptionStatus()` returns an object describing what encryption is **actually** doing — never what was merely requested. It is synchronous, safe to call at any time, and never returns the key or any part of it. + + + +```typescript +const status = CometChat.getOfflineEncryptionStatus(); +console.log( + `enabled=${status.enabled} atRest=${status.atRest} keyStorage=${status.keyStorage}` + + ` cipherAvailable=${status.cipherAvailable} cipher=${status.cipher}` +); +``` + + +```javascript +const status = CometChat.getOfflineEncryptionStatus(); +console.log( + `enabled=${status.enabled} atRest=${status.atRest} keyStorage=${status.keyStorage}` + + ` cipherAvailable=${status.cipherAvailable} cipher=${status.cipher}` +); +``` + + + +| Field | Type | Meaning | +|---|---|---| +| `enabled` | `boolean` | What was **configured** (`encryption`), not what was achieved. `false` while CometChat has offline switched off for your app. | +| `atRest` | `string` | Coverage actually achieved: `"SQLCIPHER"` once an encrypted open is proven, otherwise `"NONE"`. | +| `keyStorage` | `string` | `"KEYSTORE"` when the key came from the platform keychain; `"SOFTWARE"` when a vault reports a software fallback; `"NONE"` when this session holds no key. Read `NONE` as "no key in use", not "no key exists". | +| `cipherAvailable` | `boolean` | Whether the SQLCipher engine is present in this build. `false` when encryption is off or SQLCipher is missing. | +| `cipher` | `string \| null` | The engine's `cipher_version` once an encrypted open is proven, else `null`. | +| `leakage` | `string` | What encryption at rest never covers — the media files stored outside the database. | + + +The two most useful support signals are `enabled === true` with `cipherAvailable === false` (SQLCipher is not compiled into the app — add the `op-sqlite` flag and rebuild) and `enabled === true` with `keyStorage === "NONE"` (no secure key store — install `react-native-keychain` or set a vault). + + +## What gets encrypted + +| Data | Encrypted at rest? | +|---|---| +| Message database (messages, conversations, metadata, the pending-send outbox) | Yes, when `atRest` is `SQLCIPHER` — the whole database file, including indexes and journals | +| Downloaded media files on disk (images, video, audio, documents) | **No** — stored as ordinary files outside the database | + +Media bytes live outside the database because `Attachment.getLocalPath()` is public API and those paths are passed straight to media loaders. "Encrypted at rest" here always means the message database, never the media cache. + +## Cache resets on key loss + +If the key can no longer decrypt the stored database, the SDK wipes the local cache and rebuilds it **encrypted** — it never silently falls back to plaintext for a cache that could be encrypted. Any unsent messages still queued in the outbox at that point are lost. They lived inside the unreadable database, so they cannot be counted or reported one by one: `pendingMessagesLost` is `-1` (`CometChat.OFFLINE_PENDING_COUNT_UNKNOWN`). When the reset is `ENCRYPTION_CHANGED` instead, each queued message fails with reason `DISCARDED` and `pendingMessagesLost` is the exact count, unless the old database could not be read. + +Register an `OfflineCacheResetListener` to be told when this happens: + + + +```typescript +CometChat.addOfflineCacheResetListener("LISTENER_ID", { + onOfflineCacheReset: (reason: CometChat.OfflineCacheResetReason, pendingMessagesLost: number) => { + // pendingMessagesLost is -1 when the number lost is unknown, not zero. + console.warn("Cache reset:", reason, "pending lost:", pendingMessagesLost); + }, +}); +``` + + +```javascript +CometChat.addOfflineCacheResetListener("LISTENER_ID", { + onOfflineCacheReset: (reason, pendingMessagesLost) => { + // pendingMessagesLost is -1 when the number lost is unknown, not zero. + console.warn("Cache reset:", reason, "pending lost:", pendingMessagesLost); + }, +}); +``` + + + +The encryption-related reasons are `KEY_LOST`, `WRONG_KEY`, `CORRUPTED`, `ESCALATED`, and `ENCRYPTION_CHANGED` (you turned encryption on or off between launches, which also discards any queued messages and the cached media). See [Stay in Sync](/sdk/react-native/offline-sync) for the full cache-reset flow and the complete list of reasons, and the [API Reference](/sdk/react-native/offline-reference) for every field and enum. diff --git a/sdk/react-native/offline-media-caching.mdx b/sdk/react-native/offline-media-caching.mdx new file mode 100644 index 000000000..34c0bfb04 --- /dev/null +++ b/sdk/react-native/offline-media-caching.mdx @@ -0,0 +1,194 @@ +--- +title: "Media Caching" +sidebarTitle: "Media Caching" +description: "Cache image, video, audio and file attachments on the device so media stays viewable offline with the CometChat React Native Chat SDK." +--- + + + +| Field | Value | +| --- | --- | +| Install | `react-native-fs` (peer dependency) — without it media is not cached | +| Mode (init, fixed for the session) | `new CometChat.OfflineConfig({ enabled: true, mediaCaching: CometChat.MediaCachingMode.AUTO })` — `AUTO` (default) or `OFF` | +| Budget | `maxMediaCacheSize` (bytes, default 500 MB, `0` = unlimited) | +| Read the mode | `CometChat.getMediaCachingMode()` — `OFF` whenever offline is not active | +| Cached path | `attachment.getLocalPath()` · `await CometChat.getCachedMediaPath(attachment)` | +| Download on demand | `await CometChat.downloadMedia(attachment, listener?)` → `string \| null` | +| Background downloads | `await CometChat.addMediaDownloadListener(id, listener)` / `removeMediaDownloadListener(id)` | + +```typescript +const offlineConfig = new CometChat.OfflineConfig({ + enabled: true, + mediaCaching: CometChat.MediaCachingMode.AUTO, + maxMediaCacheSize: 500 * 1024 * 1024, +}); + +const attachment = mediaMessage.getAttachment(); +const localPath = attachment.getLocalPath(); // set when bytes are cached +const cached = await CometChat.getCachedMediaPath(attachment); // null if not cached + +const path = await CometChat.downloadMedia(attachment, { + onMediaProgress: (messageId, attachmentKey, percent) => {}, + onMediaDownloaded: (messageId, attachmentKey, localPath) => {}, + onMediaDownloadFailed: (messageId, attachmentKey, error) => {}, +}); + +await CometChat.addMediaDownloadListener("MEDIA_LISTENER", { + onMediaDownloaded: (messageId, attachmentKey, localPath) => {}, + onMediaDownloadFailed: (messageId, attachmentKey, error) => {}, +}); +``` + + + +With offline support on, the SDK can cache attachment bytes durably on the device so media stays viewable offline — even after the server's presigned URL has expired. This page covers the caching modes, what downloads automatically, how to read cached bytes, and how to download media on demand. + + +Media caching applies only while offline support is active for the logged-in user (`CometChat.isOfflineEnabled()` is `true`), and needs the optional `react-native-fs` peer dependency. See [Enable Offline Support](/sdk/react-native/offline-enable). + + + +On recent React Native versions, `react-native-fs` 2.20.0 can crash the app on Android when a download loses the network part-way. Use the maintained fork under the same module name (`"react-native-fs": "npm:@dr.pogodin/react-native-fs@~2.40.4"`; it needs the New Architecture and React Native 0.73+, and has no default export), patch `RNFSManager.reject`, or set `mediaCaching` to `OFF`. + + +## Caching modes + +The caching mode is set once, in the `OfflineConfig` you pass at initialization, and is fixed for the SDK session. There is no runtime switch — to change it, re-initialize the SDK with a new config. + +| `MediaCachingMode` | Behavior | +|---|---| +| `AUTO` (default) | Incoming media bytes are eagerly downloaded and cached on any network, then LRU-managed. The outgoing copy is retained after the server acknowledges the send. | +| `OFF` | Incoming media bytes are **not** cached. The message metadata (including the file URL) is still cached, so the message appears offline and the UI loads media live from the URL when online. The outgoing copy is dropped after the send is acknowledged. | + +Sending media always works in both modes. + +```typescript +const offlineConfig = new CometChat.OfflineConfig({ + enabled: true, + mediaCaching: CometChat.MediaCachingMode.AUTO, + maxMediaCacheSize: 500 * 1024 * 1024, +}); +``` + +Read the mode in effect at runtime with `CometChat.getMediaCachingMode()`. It returns `MediaCachingMode.OFF` whenever offline support is not active, whatever the app configured, so a UI layer can rely on it without reading the config itself. + +## What downloads automatically + +In `AUTO` mode, media bytes are downloaded and cached automatically on the two live receive paths: + +- a **realtime** media message received while connected, and +- opening a chat **online** (the server read refreshes and caches its media). + +A media message that arrives through a reconnect catch-up [sync](/sdk/react-native/offline-sync) is cached as **metadata only** and renders as a placeholder; its bytes download lazily the next time you open that chat online. This holds in both `AUTO` and `OFF`. + +In `OFF` mode, nothing is auto-downloaded on any path; media loads live from its URL when online. + +## Reading cached media + +When bytes are already cached, the SDK **hydrates the local path automatically** on every message it delivers — you do not have to ask. Read it straight off the attachment: + +```typescript +const attachment = mediaMessage.getAttachment(); +const localPath = attachment.getLocalPath(); // defined when bytes are cached +if (localPath) { + // Render directly from disk — no network fetch. +} +``` + +To look up the cached path explicitly, call `getCachedMediaPath`. It never downloads, and resolves `null` when the bytes are not cached (call `downloadMedia` to fetch them). It accepts a `MediaMessage` or a bare `Attachment`. + +```typescript +const path: string | null = await CometChat.getCachedMediaPath(attachment); +``` + + +Each attachment of a multi-attachment message resolves independently. An attachment with no cached bytes has no local path and the UI loads it from the URL as before. + + +## Downloading on demand + +Call `downloadMedia` to fetch and cache an attachment's bytes — for example when the user taps a placeholder. The SDK resolves cached bytes first, then a valid URL, then, for a CometChat-hosted URL the server rejects as expired or forbidden (HTTP 401, 403 or 410), one re-signed URL. Any other download failure is not retried with a re-signed URL. The Promise resolves with the local path, and also sets it on the attachment. It resolves `null` if the bytes could not be obtained, or if offline support is off. + + + +```typescript +const path: string | null = await CometChat.downloadMedia(attachment, { + onMediaDownloaded: (messageId: string, attachmentKey: string, localPath: string) => { + // Bytes cached; render from localPath. + }, + onMediaProgress: (messageId: string, attachmentKey: string, percent: number) => { + // 0..100, best-effort. + }, + onMediaDownloadFailed: (messageId: string, attachmentKey: string, error: { code: string; message: string }) => { + console.log(error.code); // MEDIA_OFFLINE_NOT_CACHED | MEDIA_DOWNLOAD_FAILED | ERROR_OFFLINE_DISABLED + }, +}); +``` + + +```javascript +const path = await CometChat.downloadMedia(attachment, { + onMediaDownloaded: (messageId, attachmentKey, localPath) => { + // Bytes cached; render from localPath. + }, + onMediaProgress: (messageId, attachmentKey, percent) => { + // 0..100, best-effort. + }, + onMediaDownloadFailed: (messageId, attachmentKey, error) => { + console.log(error.code); // MEDIA_OFFLINE_NOT_CACHED | MEDIA_DOWNLOAD_FAILED | ERROR_OFFLINE_DISABLED + }, +}); +``` + + + +`onMediaProgress(…, percent)` reports `0..100`, best-effort. It is emitted only when the server reports a content length; for an unknown-length download it is skipped and the call goes straight to `onMediaDownloaded`. Progress never blocks or fails the download. All three callbacks are optional. + +The failure callback receives one of these codes: + +| Error code | Meaning | +|---|---| +| `MEDIA_OFFLINE_NOT_CACHED` | The bytes are not cached and could not be fetched, for example because the device is offline, the attachment has no usable URL, or the download did not succeed. | +| `MEDIA_DOWNLOAD_FAILED` | The download raised an unexpected error. | +| `ERROR_OFFLINE_DISABLED` | Offline support is not active. | + +The listener also accepts the shorter shape `{ onSuccess(localPath), onProgress(percent), onError(error) }`. + +## Observe background downloads + +In `AUTO` mode, incoming media downloads in the background with no per-call callback. Register a media download listener to learn when the bytes land (or fail) so a UI tile can refresh. + + + +```typescript +await CometChat.addMediaDownloadListener("MEDIA_LISTENER", { + onMediaDownloaded: (messageId: string, attachmentKey: string, localPath: string) => { + // messageId: the message's server id (or local id if not yet sent) + // attachmentKey: the attachment URL without its query string + // localPath: where the bytes landed + }, + onMediaDownloadFailed: (messageId: string, attachmentKey: string, error: { code: string; message: string }) => {}, +}); +``` + + +```javascript +await CometChat.addMediaDownloadListener("MEDIA_LISTENER", { + onMediaDownloaded: (messageId, attachmentKey, localPath) => { + // messageId: the message's server id (or local id if not yet sent) + // attachmentKey: the attachment URL without its query string + // localPath: where the bytes landed + }, + onMediaDownloadFailed: (messageId, attachmentKey, error) => {}, +}); +``` + + + +Remove the listener with `CometChat.removeMediaDownloadListener("MEDIA_LISTENER")`. The background listener carries no progress callback — progress is reported only on the explicit `downloadMedia` path above. + +## Media cache size limit + +Cached media bytes are bounded by `maxMediaCacheSize` (default **500 MB**; `0` means unlimited). When the cache exceeds the limit, the least recently accessed media is deleted first until it is back under budget. Media for un-sent (queued) messages and in-progress uploads is never evicted and does not count toward the budget. Bulk list loads and syncs do not refresh an item's recency. + +Media is stored as files in an app-private, per-user directory; the location is not configurable. Eviction runs silently in the background — there is no cache-limit callback. To reason about size, use `CometChat.getCacheSize()` and the configured limits. See the [configuration reference](/sdk/react-native/offline-reference) for the full set of options. diff --git a/sdk/react-native/offline-overview.mdx b/sdk/react-native/offline-overview.mdx new file mode 100644 index 000000000..46e8149fc --- /dev/null +++ b/sdk/react-native/offline-overview.mdx @@ -0,0 +1,95 @@ +--- +title: "Offline Support" +sidebarTitle: "Offline Support" +description: "Understand how local caching, the send outbox, and reconnect catch-up keep chat working without a connection with the CometChat React Native Chat SDK." +--- + + + +| Field | Value | +| --- | --- | +| Package | `@cometchat/chat-sdk-react-native` (4.2.0 or later) | +| Default | **Off.** Enable with `new CometChat.OfflineConfig({ enabled: true })` on `AppSettingsBuilder.setOfflineConfig()` | +| Native peers | `@op-engineering/op-sqlite` (required), `react-native-fs` (media), `react-native-keychain` (encryption) | +| Check it is active | `CometChat.isOfflineEnabled()` | +| Reads | Pass a listener to `fetchNext()` / `fetchPrevious()` — `onCached…` fires first, then `onServer…` | +| Sends | Pass a `MessageSentListener` to `sendMessage()` — `onMessagePending`, then `onMessageSent` or `onError` | +| Related | [Enable](/sdk/react-native/offline-enable) · [Cached data](/sdk/react-native/offline-cached-data) · [Send](/sdk/react-native/offline-send) · [Sync](/sdk/react-native/offline-sync) · [Reference](/sdk/react-native/offline-reference) | + +```typescript +const offlineConfig = new CometChat.OfflineConfig({ enabled: true }); + +const appSettings = new CometChat.AppSettingsBuilder() + .setRegion("REGION") + .setOfflineConfig(offlineConfig) // or .offline(true) for defaults + .build(); + +await CometChat.init("APP_ID", appSettings); + +const active: boolean = CometChat.isOfflineEnabled(); +``` + + + +Offline support keeps the logged-in user's conversations and messages on the device so your app can show them instantly, lets the user send messages while disconnected, and catches up automatically when the connection returns. It is **off by default** — nothing changes in your app until you enable it ([Enable Offline Support](/sdk/react-native/offline-enable)). This page explains the mental model; the other pages show the exact APIs. + + +Offline support requires CometChat React Native Chat SDK **4.2.0** or later. + + +## How it works + +Three ideas carry the whole feature. + + + +Every read that you pass a listener to goes through the local cache first. For list reads the SDK delivers the cached page immediately, even when it is empty. Single-item lookups (`getMessageDetails`, `getConversation`) deliver a cached result only when that item is already cached. The SDK then fetches from the server and delivers the fresh result. Your UI renders what is on the device without waiting for the network, then updates when the server responds. See [Show Cached Data](/sdk/react-native/offline-cached-data). + + +A message you send with a `MessageSentListener` while offline is saved to a durable outbox and reported as pending instead of failing. It survives app restarts and is sent when the connection returns. See [Send While Offline](/sdk/react-native/offline-send). + + +When the device reconnects, the SDK syncs the conversations and messages that changed while it was away and flushes the outbox, so the local cache matches the server again. See [Stay in Sync](/sdk/react-native/offline-sync). + + + +```mermaid +flowchart LR + A[App read] --> B[(Local cache)] + B -- cached page first --> A + B --> C{Online?} + C -- yes --> D[Server] + D -- fresh result --> A + C -- no --> E[onError:\nFAILED_TO_FETCH] + F[Send while offline] --> G[(Durable outbox)] + G -- on reconnect --> D +``` + +## What changes in your app + +With offline support enabled, the read and send methods you already call keep working, and gain two things. Reads still return a Promise that settles on the server result; if you also pass a listener, it receives the cached page first and then the server page. Sends still return a Promise; if you also pass a `MessageSentListener`, it is told when the message is queued (`onMessagePending`) and when it is finally sent. To receive the cached and pending stages you must pass these listeners — the Promise-only forms are unchanged. A send without a listener is never queued: it goes straight to the server and fails if there is no connection. With offline support off, every API behaves exactly as it did before. + +## One cache per user + +The SDK keeps a single local cache for the user who is logged in, per app. + +- The cache survives app restarts while the same user stays logged in, so the data is there instantly on next launch. +- Logging out, a session expiring, or logging in as a **different** user wipes the cache, including any unsent messages still in the outbox, which are lost. You never clear it yourself — the SDK does it. + +See [Stay in Sync](/sdk/react-native/offline-sync) for cache resets and logout behaviour. + +## How much is kept + +By default the cache is bounded by size, not age: it holds up to **100 conversations**, up to **500 messages per conversation**, around **250 MB** of data, and up to **500 MB** of cached media. Older entries are evicted first as these limits are reached. Every limit is tunable — see the [Configuration & API Reference](/sdk/react-native/offline-reference). + +## Next steps + + + Turn it on and verify it is active. + The cached-first, server-next read contract. + The durable outbox and pending messages. + Reconnect catch-up, cache resets, logout. + Download and cache attachments for offline viewing. + Encrypt the local database at rest. + Every option, method, listener, enum, and error. + diff --git a/sdk/react-native/offline-reference.mdx b/sdk/react-native/offline-reference.mdx new file mode 100644 index 000000000..292dc4d4a --- /dev/null +++ b/sdk/react-native/offline-reference.mdx @@ -0,0 +1,185 @@ +--- +title: "Offline Configuration & API Reference" +sidebarTitle: "Configuration & API Reference" +description: "A complete reference for the offline configuration options, public methods, listeners, enums, model fields, and error codes with the CometChat React Native Chat SDK." +--- + + + +| Field | Value | +| --- | --- | +| Config | `new CometChat.OfflineConfig({ enabled: true, ... })` → `AppSettingsBuilder.setOfflineConfig()` (or `.offline(true)`) | +| Introspection | `isOfflineEnabled()` · `getMediaCachingMode()` · `getOfflineEncryptionStatus()` · `getSyncState()` · `await getLastSyncTimestamp()` · `await getCacheSize()` | +| Outbox | `await getPendingMessages()` · `retryMessage(localId)` · `retryAllFailedMessages()` · `discardLocalMessage(localId)` | +| Control | `forceSyncNow()` · `clearOfflineCache()` | +| Minimum SDK | 4.2.0 | + +```typescript +const offlineConfig = new CometChat.OfflineConfig({ + enabled: true, + maxConversations: 100, + maxMessagesPerConversation: 500, + maxPendingMessages: 50, + mediaCaching: CometChat.MediaCachingMode.AUTO, + encryption: false, +}); +``` + + + +This page is the full surface of CometChat's offline support in the React Native Chat SDK: configuration options, public methods, listeners, enums, model fields, and error codes. The minimum SDK version is **4.2.0**, and offline support is **off by default**. For task-focused guides see [Enable Offline Support](/sdk/react-native/offline-enable), [Show Cached Data](/sdk/react-native/offline-cached-data), [Send While Offline](/sdk/react-native/offline-send), [Stay in Sync](/sdk/react-native/offline-sync), [Media Caching](/sdk/react-native/offline-media-caching), and [Encrypt the Cache](/sdk/react-native/offline-encryption). + +## OfflineConfig options + +Construct with `new CometChat.OfflineConfig(options)`; every key is optional and pre-set to its default, so pass only the options you want to change. The resulting `OfflineConfig` is immutable. Invalid values are normalised, never rejected: a wrong type or unknown enum value falls back to the default, and a negative duration becomes `0`. Byte caps and the TTL treat `<= 0` as **unlimited** (that dimension's eviction is disabled); the count caps are clamped and have no "unlimited". + +| Option | Type | Default | Range / clamp | Effect | +|---|---|---|---|---| +| `enabled` | `boolean` | `false` | — | Master gate for the whole offline engine. With it off the SDK behaves exactly as before. | +| `cacheDurationDays` | `number` (int) | `0` (unlimited) | `>= 0`; negative treated as `0` | Message retention TTL in days. `0` = no age-based prune; eviction is by the byte/count caps instead. | +| `maxStorageSize` | `number` bytes | `262144000` (250 MB) | `<= 0` = unlimited | Byte ceiling for the message/metadata database. | +| `maxMessagesPerConversation` | `number` | `500` | clamped `[1, 1000]`; `<= 0` or over → `1000` | Per-conversation cached-message window. | +| `maxConversations` | `number` | `100` | clamped `[1, 1000]`; `<= 0` or over → `1000` | Cached-conversation cap. | +| `maxPendingMessages` | `number` | `50` | clamped `[1, 100]`; `<= 0` or over → `100` | Pending-send outbox capacity. | +| `autoSendPendingOnReconnect` | `boolean` | `true` | — | Auto-flush queued `PENDING` sends on reconnect. Gates only the outbox flush; the reconnect sync sweep runs regardless. | +| `pendingQueueOverflowPolicy` | `PendingMessageOverflowPolicy` | `REJECT_NEWEST` | — | What the outbox does when it is full. | +| `maxMediaCacheSize` | `number` bytes | `524288000` (500 MB) | `<= 0` = unlimited | Media-only byte budget (LRU). | +| `mediaCaching` | `MediaCachingMode` | `AUTO` | unknown → `AUTO` | Receive-side media mode. Fixed at init. | +| `encryption` | `boolean` | `false` | — | Encrypt the cache database at rest. See [Encrypt the Cache](/sdk/react-native/offline-encryption). | +| `encryptionFailureBehavior` | `EncryptionFailureBehavior` | `ONLINE_ONLY` | unknown → `ONLINE_ONLY` | What happens when encryption cannot run at all. | +| `maxSnapshotQueries` | `number` | `100` | `>= 1`; `0`/negative → `100` | Most entity-list snapshots kept for offline replay, across users, groups, group members, banned members, reactions, the notification feed and threads with `setParticipatedByMe(false)`; LRU. | +| `snapshotTtlDays` | `number` | `7` | `>= 1`; `0`/negative → `7` | Days an entity-list snapshot is kept after it was first recorded. | + +Attach the config at init via `AppSettingsBuilder.setOfflineConfig(OfflineConfig)`, or enable with defaults via the `offline(boolean)` shorthand — see [Enable Offline Support](/sdk/react-native/offline-enable). + +The config object also exposes read-only getters: `isEnabled()`, `isEncryptionEnabled()`, `getEncryptionFailureBehavior()`, `getCacheDurationDays()`, `getMaxStorageSize()`, `getMaxMessagesPerConversation()`, `getMaxConversations()`, `getMaxSnapshotQueries()`, `getSnapshotTtlDays()`, `getMaxPendingMessages()`, `isAutoSendPendingOnReconnect()`, `getPendingQueueOverflowPolicy()`, `getMediaCaching()` and `getMaxMediaCacheSize()`. + +## Public methods on CometChat + +| Method | Returns | Purpose | +|---|---|---| +| `isOfflineEnabled()` | `boolean` | Whether offline support is active for the current session. | +| `getMediaCachingMode()` | `MediaCachingMode` | The effective media caching mode (`OFF` whenever offline is not active). | +| `getOfflineEncryptionStatus()` | `{ enabled, atRest, keyStorage, cipherAvailable, cipher, leakage }` | What encryption at rest is actually doing. | +| `setOfflineKeyVault(vault \| null)` | `void` | Supply your own encryption-key store. Call before `init`. | +| `getPendingMessages()` | `Promise` | All queued/failed messages in the outbox. | +| `retryMessage(localMessageId, listener?)` | `Promise` (or `void` with a listener) | Retry one failed or pending message by its local id. | +| `retryAllFailedMessages(listener?)` | `Promise` (or `void` with a listener) | Retry every `TRANSIENT` failed message in the outbox. | +| `discardLocalMessage(localMessageId, listener?)` | `Promise` (or `void` with a listener) | Remove one unsent message from the outbox. | +| `forceSyncNow(callback?)` | `Promise` | Trigger a catch-up sync immediately. Resolves when accepted; rejects (or calls `callback.onError`) with the reason it could not start. A sync already running or one that ran within the last 10 seconds is refused, not queued. | +| `getSyncState()` | `SyncState` | Whether a catch-up sweep is currently running. | +| `getLastSyncTimestamp()` | `Promise` | Server time of the last successful sync, in epoch seconds. | +| `clearOfflineCache()` | `Promise` | Empty the local cache in place (and fail any unsent messages as `DISCARDED`). | +| `getCacheSize()` | `Promise` | Current on-disk cache size in bytes (database plus media). | +| `downloadMedia(messageOrAttachment, listener?)` | `Promise` | Download and cache an attachment's bytes. | +| `getCachedMediaPath(messageOrAttachment)` | `Promise` | Local path to already-cached bytes, or `null`. | +| `getExistingUploadFileRequest(receiverId, receiverType, parentMessageId?)` | `Promise` | Re-attach to an in-progress offline upload. | + +On the returned `UploadFileRequest`, `getItems()` lists each file's progress item and `retryAttachment(fileId)` retries one failed file. New batches are created with `CometChat.createUploadFileRequest`. + +### Listener registration + +| Method | Listener | +|---|---| +| `addMessageListener(id, MessageListener)` / `removeMessageListener(id)` | `MessageListener` (adds `onMessagesSynced`) | +| `addOfflineMessageListener(id, OfflineMessageListener)` / `removeOfflineMessageListener(id)` | `OfflineMessageListener` | +| `addSyncListener(id, SyncListener)` / `removeSyncListener(id)` | `SyncListener` | +| `addOfflineCacheResetListener(id, OfflineCacheResetListener)` / `removeOfflineCacheResetListener(id)` | `OfflineCacheResetListener` | +| `addMediaDownloadListener(id, MediaDownloadListener)` / `removeMediaDownloadListener(id)` | `MediaDownloadListener` | + +## Listeners and callbacks + +### Two-phase fetch listeners + +Pass one as the optional argument of a request's `fetchNext()` / `fetchPrevious()` (or of `getConversation()` / `getMessageDetails()`). Each delivers the cached page first (when offline is active and a user is logged in), then the server result, then errors. With offline off the cached callback never fires. The cached callback is optional on every listener. `onError` is required on all of them; the server callback is required on all except `BannedGroupMemberFetchListener` and `FeedItemFetchListener`, where each server callback is optional. + +| Listener | Callbacks | +|---|---| +| `MessageFetchListener` | `onCachedMessages(messages)`, `onServerMessages(messages)`, `onError(error)` | +| `ThreadFetchListener` (alias `ThreadsFetchListener`) | `onCachedThreads(threads)`, `onServerThreads(threads)`, `onError(error)` | +| `MessageDetailsListener` | `onCachedMessage(message)`, `onServerMessage(message)`, `onError(error)` | +| `ConversationFetchListener` | `onCachedConversations(conversations)`, `onServerConversations(conversations)`, `onError(error)` | +| `ConversationDetailsListener` | `onCachedConversation(conversation)`, `onServerConversation(conversation)`, `onError(error)` | +| `UserFetchListener` | `onCachedUsers(users)`, `onServerUsers(users)`, `onError(error)` | +| `GroupFetchListener` | `onCachedGroups(groups)`, `onServerGroups(groups)`, `onError(error)` | +| `GroupMemberFetchListener` | `onCachedGroupMembers(members)`, `onServerGroupMembers(members)`, `onError(error)` | +| `BannedGroupMemberFetchListener` | `onCachedBannedGroupMembers(members)`, `onServerBannedGroupMembers(members)`, `onError(error)` | +| `ReactionFetchListener` | `onCachedReactions(reactions)`, `onServerReactions(reactions)`, `onError(error)` | +| `FeedItemFetchListener` (alias `NotificationFeedFetchListener`) | `onCachedNotificationFeedItems(items)`, `onServerNotificationFeedItems(items)`, `onError(error)` | + +### Send, sync, cache, and media listeners + +| Listener | Callbacks | +|---|---| +| `MessageSentListener` | `onMessagePending(message)`, `onMessageSent(message)`, `onError(error, reason?)` | +| `OfflineMessageListener` | `onOutboxMessagePending(message)`, `onOutboxMessageSent(message)`, `onOutboxMessageFailed(message, reason)` | +| `DiscardMessageListener` | `onSuccess(localMessageId)`, `onError(error)` | +| `SyncListener` | `onConversationSyncStarted()`, `onConversationSyncCompleted(conversations)`, `onConversationSyncError(error)`, `onMessageSyncStarted()`, `onMessageSyncCompleted(messages, outcome)`, `onMessageSyncError(error)` | +| `OfflineCacheResetListener` | `onOfflineCacheReset(reason, pendingMessagesLost)` | +| `DownloadMediaListener` (per call) | `onMediaDownloaded(messageId, attachmentKey, localPath)`, `onMediaProgress(messageId, attachmentKey, percent)`, `onMediaDownloadFailed(messageId, attachmentKey, error)`; also `onSuccess(localPath)`, `onProgress(percent)`, `onError(error)` | +| `MediaDownloadListener` (global) | `onMediaDownloaded(messageId, attachmentKey, localPath)`, `onMediaDownloadFailed(messageId, attachmentKey, error)` | +| `MessageListener` | `onMessagesSynced(messages)` (offline addition to the existing listener) | + +## Enums + +| Enum | Values | +|---|---| +| `MediaCachingMode` | `AUTO` (auto-download + retain received media), `OFF` (skip auto-download; metadata still cached) | +| `PendingMessageOverflowPolicy` | `DROP_OLDEST` (evict the oldest pending to admit a newer send), `REJECT_NEWEST` (reject the new send) | +| `EncryptionFailureBehavior` | `ONLINE_ONLY` (online-only; never plaintext), `PLAINTEXT_FALLBACK` (open an unencrypted cache) | +| `MessageStatus` | `PENDING`, `SENDING`, `SENT`, `DELIVERED`, `READ`, `FAILED` | +| `MessageFailureReason` | `TRANSIENT` (retry likely helps), `PERMANENT` (retry won't help), `QUEUE_OVERFLOW` (evicted to admit a newer send), `QUEUE_FULL` (new send rejected, queue full), `BYTE_OVERFLOW` (media exceeded the byte cap), `DISCARDED` (removed before it could send) | +| `MessageSource` | `REALTIME`, `SYNC` | +| `SyncOutcome` | `CAUGHT_UP` (gap replayed exactly), `PARTIAL` (a page was ingested but more remains) | +| `SyncState` | `IDLE`, `SYNCING` | +| `OfflineCacheResetReason` | `KEY_LOST`, `WRONG_KEY`, `CORRUPTED`, `ESCALATED` (unclassified failure recurred and was escalated), `ENCRYPTION_CHANGED` (the `encryption` setting changed between launches) | +| Upload item `state` | `QUEUED`, `UPLOADING`, `UPLOADED`, `FAILED`, `REJECTED` | + +All enums are available on `CometChat` (for example `CometChat.MessageStatus.PENDING`). `CometChat.OFFLINE_PENDING_COUNT_UNKNOWN` is `-1`. + +## Model fields + +| Field | Type | Meaning | +|---|---|---| +| `BaseMessage.getLocalMessageId()` | `string \| undefined` | SDK-generated local handle for an optimistic/offline send; the stable key to reconcile the row when the server ack arrives. `undefined` for messages that never went through the outbox. | +| `BaseMessage.getMessageStatus()` | `MessageStatus` | Derived lifecycle status of the message. Not the same as `getStatus()`. | +| `BaseMessage.getFailureReason()` | `MessageFailureReason \| undefined` | Why a queued send is `FAILED`; otherwise `undefined`. | +| `BaseMessage.getSource()` | `MessageSource` | `SYNC` for a message delivered by a catch-up sync, otherwise `REALTIME`. | +| `Attachment.getLocalPath()` | `string \| undefined` | Absolute device path to cached media bytes, or `undefined` when not cached. Never sent to the server. | + +## Error codes + +Compare `CometChatException.code` against these values. The offline codes are available on `CometChat.OfflineErrorCode` (`MESSAGE_DISCARDED`, `MESSAGE_IN_FLIGHT`, `MESSAGE_NOT_FOUND`, `AUTO_FLUSH_CAP_REACHED`, `PENDING_QUEUE_LIMIT`, `DISABLED`, `SYNC_IN_PROGRESS`, `SYNC_COOLDOWN`, `MESSAGE_PERSIST_FAILED`); compare the others as string values. + +| Code | Raised when | +|---|---| +| `FAILED_TO_FETCH` | A read reached the server phase with no connection (fires after the cached page on a two-phase fetch, and rejects the Promise). On a send, the same code with `errorParams.queued === true` is the "queued, not failed" notice. | +| `ERROR_OFFLINE_SYNC_IN_PROGRESS` | `forceSyncNow()` was called while a sync is already running. | +| `ERROR_OFFLINE_SYNC_COOLDOWN` | `forceSyncNow()` was called within the 10-second throttle cooldown; `details.retryAfterMs` says when to retry. | +| `ERROR_OFFLINE_DISABLED` | An offline-only operation was called while offline support is not active. | +| `USER_NOT_LOGED_IN` | `forceSyncNow()` was called with no logged-in user. | +| `ERROR_OFFLINE_MESSAGE_DISCARDED` | A queued message was removed before it could send: by `clearOfflineCache()`, a cache rebuild, or leaving or deleting its conversation. A logout ends unsent sends without calling any listener. | +| `ERROR_OFFLINE_MESSAGE_IN_FLIGHT` | `retryMessage` / `discardLocalMessage` targeted a message that is still being sent. | +| `ERROR_OFFLINE_MESSAGE_NOT_FOUND` | `retryMessage` / `discardLocalMessage` found no unsent message with the given local id. | +| `ERROR_OFFLINE_MESSAGE_PERSIST_FAILED` | The local cache was briefly unavailable, so `getPendingMessages`, `retryMessage`, `retryAllFailedMessages` or `discardLocalMessage` changed nothing. Try again. | +| `REQUEST_IN_PROGRESS` | A fetch was started while an earlier fetch on the same request object is still running. Raised for messages, threads, reactions and the notification feed; see [Show Cached Data](/sdk/react-native/offline-cached-data#the-two-phase-contract). | +| `ERROR_OFFLINE_MESSAGE_UNSENDABLE` | A queued message could not be restored for sending (corrupt data or missing media); it fails `PERMANENT`. | +| `ERROR_OUTBOX_AUTO_FLUSH_CAP_REACHED` | A queued message got no answer on 5 reconnects in a row; it is now `FAILED` with reason `TRANSIENT`. | +| `ERROR_PENDING_QUEUE_LIMIT` | A send was refused or evicted because the outbox is full or the media is too large; the reason is `QUEUE_FULL`, `QUEUE_OVERFLOW` or `BYTE_OVERFLOW`. | +| `MEDIA_OFFLINE_NOT_CACHED` / `MEDIA_DOWNLOAD_FAILED` | The `error.code` passed to a media download failure callback. | + +## Which filters the cache can answer + +A cached phase is served only when every filter on the request is locally computable; otherwise the cached phase is empty and only the server phase answers. + +| Request | Filters that blank the cached phase | +|---|---| +| `MessagesRequest` | `searchKeyword`, `hasLinks`, `updatesOnly`, `interactionGoalCompletedOnly`, `UID` and `GUID` together (outside a thread) | +| `ConversationsRequest` | `searchKeyword` | +| `UsersRequest` | `searchKeyword`, user `status` | +| `GroupMembersRequest` | `searchKeyword`, member `status` | +| `GroupsRequest`, `BannedMembersRequest` | `searchKeyword` | + +`MessagesRequest.hideMessagesFromBlockedUsers` is answered from the cache only after the blocked-users list has been fetched from the server at least once; until then its cached phase is empty. Pinned-only and saved-only message reads are answered from the cache too. + +All other filters — including message `tags`, mentions, attachment and reaction filters, `hideReplies`, `hideDeletedMessages`, and conversation `tags` and `unread` — are answered from the cache. diff --git a/sdk/react-native/offline-send.mdx b/sdk/react-native/offline-send.mdx new file mode 100644 index 000000000..830c63df4 --- /dev/null +++ b/sdk/react-native/offline-send.mdx @@ -0,0 +1,352 @@ +--- +title: "Send While Offline" +sidebarTitle: "Send While Offline" +description: "Queue messages sent without a connection, track their status, and retry failed sends with the CometChat React Native Chat SDK." +--- + + + +| Field | Value | +| --- | --- | +| Send | `CometChat.sendMessage(message, listener)` — also `sendMediaMessage`, `sendCustomMessage`, `sendInteractiveMessage` | +| Listener | `MessageSentListener`: `onMessagePending` → `onMessageSent` or `onError(error, reason)`. Only a send with a listener is queued. | +| Message state | `message.getLocalMessageId()`, `message.getMessageStatus()`, `message.getFailureReason()` | +| Outbox | `getPendingMessages()`, `retryMessage(localId)`, `retryAllFailedMessages()`, `discardLocalMessage(localId)` | +| App-wide events | `addOfflineMessageListener(id, listener)` / `removeOfflineMessageListener(id)` | +| Prerequisites | [Offline support enabled](/sdk/react-native/offline-enable) | + +```typescript +CometChat.sendMessage(textMessage, { + onMessagePending: (message) => { /* getMessageStatus() === PENDING, fires immediately */ }, + onMessageSent: (message) => { /* server ack: real id + sentAt */ }, + onError: (error, reason) => { /* reason == null: queued, not failed. reason set: real failure */ }, +}); + +const localId = message.getLocalMessageId(); +const status = message.getMessageStatus(); // PENDING | SENDING | SENT | DELIVERED | READ | FAILED +const why = message.getFailureReason(); // set only while FAILED + +await CometChat.getPendingMessages(); // PENDING + SENDING + FAILED +await CometChat.retryMessage(localId); +await CometChat.retryAllFailedMessages(); // transient failures only +await CometChat.discardLocalMessage(localId); // delete an unsent message +``` + + + +With offline support enabled, a message you send while the device has no connection is **durably queued on the device** instead of failing. It keeps its place across an app restart and is sent automatically once the connection is back and stable (with `autoSendPendingOnReconnect` on, the default). This page shows the offline-aware send listener, how to read a message's status, how to inspect and recover the send queue, and the queue limits. + + +Offline support is **off by default** and requires SDK **4.2.0** or newer. Queuing only happens once you [enable it](/sdk/react-native/offline-enable); with offline off, a send over no connection fails exactly as before. + + +## Send a Message + +Pass a `MessageSentListener` as the second argument to `sendMessage`. Its lifecycle is normally `onMessagePending` first (the message is durably queued), then its outcome: `onMessageSent` (the server accepted it) or `onError`. Offline, `onError` is a queued-not-failed notice and `onMessageSent` still fires later on reconnect, so both can fire for one send (see below). If the message cannot be queued at all, for example because its media could not be copied (reason `PERMANENT`), only `onError` fires. + + + +```typescript +const textMessage: CometChat.TextMessage = new CometChat.TextMessage( + "UID", + "Hello!", + CometChat.RECEIVER_TYPE.USER +); + +CometChat.sendMessage(textMessage, { + onMessagePending: (message: CometChat.BaseMessage) => { + // Queued and persisted; message.getMessageStatus() === CometChat.MessageStatus.PENDING + console.log("Pending, local id:", message.getLocalMessageId()); + }, + onMessageSent: (message: CometChat.BaseMessage) => { + // Server ack: message now carries its real id and sentAt + console.log("Sent:", message.getId()); + }, + onError: (error: CometChat.CometChatException, reason?: CometChat.MessageFailureReason | null) => { + // reason == null: queued, not failed. Otherwise a real failure. + console.log("Send error:", error.code, reason); + }, +}); +``` + + +```javascript +const textMessage = new CometChat.TextMessage( + "UID", + "Hello!", + CometChat.RECEIVER_TYPE.USER +); + +CometChat.sendMessage(textMessage, { + onMessagePending: (message) => { + // Queued and persisted; message.getMessageStatus() === CometChat.MessageStatus.PENDING + console.log("Pending, local id:", message.getLocalMessageId()); + }, + onMessageSent: (message) => { + // Server ack: message now carries its real id and sentAt + console.log("Sent:", message.getId()); + }, + onError: (error, reason) => { + // reason == null: queued, not failed. Otherwise a real failure. + console.log("Send error:", error.code, reason); + }, +}); +``` + + + +The same `MessageSentListener` is accepted by all four send methods: + +```typescript +CometChat.sendMessage(textMessage, listener); // TextMessage +CometChat.sendMediaMessage(mediaMessage, listener); // MediaMessage +CometChat.sendCustomMessage(customMessage, listener); // CustomMessage +CometChat.sendInteractiveMessage(interactiveMessage, listener); // InteractiveMessage +``` + + +Passing a listener does not change the return value — each method still returns its Promise, and the Promise settles on the server outcome. Consume the final result from **one** channel, the Promise or the listener, not both. `onMessagePending` fires only while offline support is active. + +Only a send **with a listener** is queued. A send with no listener goes straight to the server and is never queued, so while offline it rejects and is not sent later. If you pass a listener and do not use the Promise, add a `.catch(() => {})` to it so a rejection is not reported as unhandled. + + +## Queued, Not Failed + +When the device is offline, the send still fires `onMessagePending`, then fires `onError` **once** with the not-connected error: code `FAILED_TO_FETCH`, `errorParams.queued === true`, and `reason` equal to `null`. The Promise rejects with the same error. This is a **notification, not a failure**: the message stays durably `PENDING`, and `onMessageSent` fires **later**, on reconnect, when it actually goes. Do not send it again. This is the default behaviour, with `autoSendPendingOnReconnect` on. With it off, an offline send becomes `FAILED` (`TRANSIENT`) right away: `onError` fires with a reason, and you recover the message with `retryMessage`. + +So for a single send, `onError` and `onMessageSent` can **both** fire (offline, then reconnect), and `onError` does not always mean the message failed. Use `reason` (or `error.errorParams.queued`) to tell the two apart: + +| `onError` arguments | Meaning | What to do | +|---|---|---| +| `reason == null`, `error.errorParams.queued === true` | Queued; not sent yet. Auto-sends on reconnect (with `autoSendPendingOnReconnect` on, the default). | Show as sending/queued; nothing to do. | +| `reason` is a `MessageFailureReason` | A real failure; will not retry on its own. | Offer the user a manual retry (see below). | + + + +```typescript +onError: (error: CometChat.CometChatException, reason?: CometChat.MessageFailureReason | null) => { + if (reason == null) { + // Queued offline — auto-sends on reconnect + } else { + // Real failure — surface a retry action + } +}, +``` + + +```javascript +onError: (error, reason) => { + if (reason == null) { + // Queued offline — auto-sends on reconnect + } else { + // Real failure — surface a retry action + } +}, +``` + + + +### Message status and failure reason + +Every message exposes its offline state. `getLocalMessageId()` returns the SDK-generated handle that is stamped at compose (while offline is running) and stays stable across the whole lifecycle — use it to retry or discard the message. + +| Accessor | Type | Notes | +|---|---|---| +| `getLocalMessageId()` | `string \| undefined` | Client handle; present while offline is running. | +| `getMessageStatus()` | `MessageStatus` | `PENDING`, `SENDING`, `SENT`, `DELIVERED`, `READ`, `FAILED`. | +| `getFailureReason()` | `MessageFailureReason \| undefined` | Set only while `FAILED`; otherwise `undefined`. | + + +Read the offline status with `getMessageStatus()`. `BaseMessage.getStatus()` is the existing call/message status field and is **not** the offline status. + + +`MessageFailureReason` values: + +| Reason | When | +|---|---| +| `TRANSIENT` | A temporary server error (5xx, or 429 rate-limited), no answer after 5 automatic reconnect attempts in a row, or, with `autoSendPendingOnReconnect` off, no answer at all. Retryable. | +| `PERMANENT` | The server rejected the message, or the error was not recognized. A manual retry will not help. | +| `QUEUE_FULL` | A new send was refused because the queue is full (count cap). | +| `QUEUE_OVERFLOW` | An older queued message was evicted to admit a newer one (`DROP_OLDEST` only). | +| `BYTE_OVERFLOW` | A media send was refused because its bytes do not fit in `maxMediaCacheSize`, the budget for the whole media cache, even after cached received media is evicted to make room. | +| `DISCARDED` | A queued message was removed before it could send: by `clearOfflineCache()`, by a cache rebuild, or by leaving or deleting its conversation. Reported with code `ERROR_OFFLINE_MESSAGE_DISCARDED`. (`discardLocalMessage` reports `onSuccess` instead. A logout ends unsent sends without calling any listener.) | + +## Inspect the Queue + +`getPendingMessages` returns the **entire un-acked outbox** — every `PENDING`, `SENDING`, and `FAILED` message, ordered by local compose time. Sent messages are not included. Read each message's status to group them. It returns an empty list when offline support is off. + + + +```typescript +CometChat.getPendingMessages().then( + (messages: CometChat.BaseMessage[]) => { + messages.forEach((m) => console.log(m.getLocalMessageId(), "->", m.getMessageStatus())); + }, + (error: CometChat.CometChatException) => { + console.log("Could not read the outbox:", error.code); + } +); +``` + + +```javascript +CometChat.getPendingMessages().then( + (messages) => { + messages.forEach((m) => console.log(m.getLocalMessageId(), "->", m.getMessageStatus())); + }, + (error) => { + console.log("Could not read the outbox:", error.code); + } +); +``` + + + +If the local store is briefly unavailable, the call rejects with `ERROR_OFFLINE_MESSAGE_PERSIST_FAILED` rather than returning an empty list — try again. + +### Observe the queue app-wide + +The per-call `MessageSentListener` lives only as long as the call. To watch every queue transition — a live send, a reconnect auto-flush, a manual retry, or a resumed send after an app restart — register a global `OfflineMessageListener`. Its failed callback carries the `MessageFailureReason`. + +After an app relaunch with a saved login, add this listener straight after `await CometChat.init(...)` resolves, before you await anything else. Messages that fail during startup, for example when the SDK has to rebuild the cache, are delivered just after `init` resolves. A listener added later does not receive them. + + + +```typescript +CometChat.addOfflineMessageListener("LISTENER_ID", { + onOutboxMessagePending: (message: CometChat.BaseMessage) => {}, + onOutboxMessageSent: (message: CometChat.BaseMessage) => {}, + onOutboxMessageFailed: (message: CometChat.BaseMessage, reason: CometChat.MessageFailureReason) => { + console.log("Failed:", message.getLocalMessageId(), reason); + }, +}); + +// Remove it when no longer needed +CometChat.removeOfflineMessageListener("LISTENER_ID"); +``` + + +```javascript +CometChat.addOfflineMessageListener("LISTENER_ID", { + onOutboxMessagePending: (message) => {}, + onOutboxMessageSent: (message) => {}, + onOutboxMessageFailed: (message, reason) => { + console.log("Failed:", message.getLocalMessageId(), reason); + }, +}); + +// Remove it when no longer needed +CometChat.removeOfflineMessageListener("LISTENER_ID"); +``` + + + +## Retry and Discard + +There is no automatic retry of a `FAILED` message — recovery is explicit and user-invoked. A `FAILED` message stays in the outbox until you retry or discard it. Use the message's local id. Each method takes an optional listener as a second argument; omit it and you get a Promise instead (never both). + +- **`retryMessage(localMessageId)`** re-attempts one message. A `FAILED` message is reset to `PENDING` and sent; a `PENDING` message is attempted now rather than waiting for a reconnect. The result is that attempt's outcome. +- **`retryAllFailedMessages()`** retries **transient** failures only; permanent ones are skipped. The Promise form resolves once the retries are issued; pass a `MessageSentListener` to see each outcome. +- **`discardLocalMessage(localMessageId)`** deletes an unsent (`PENDING` or `FAILED`) message from the device, including its copied media. The discard's own listener (`DiscardMessageListener`) gets `onSuccess(localMessageId)`; the original send's listener is released with no final outcome. + + + +```typescript +await CometChat.retryMessage(localMessageId); + +await CometChat.retryAllFailedMessages(); + +CometChat.discardLocalMessage(localMessageId).then( + () => console.log("Discarded"), + (error: CometChat.CometChatException) => console.log("Discard rejected:", error.code) +); +``` + + +```javascript +await CometChat.retryMessage(localMessageId); + +await CometChat.retryAllFailedMessages(); + +CometChat.discardLocalMessage(localMessageId).then( + () => console.log("Discarded"), + (error) => console.log("Discard rejected:", error.code) +); +``` + + + +`retryMessage` and `discardLocalMessage` reject, changing nothing, when: + +| Error code | Reason | +|---|---| +| `ERROR_OFFLINE_MESSAGE_IN_FLIGHT` | The message is already sending (`SENDING`). | +| `ERROR_OFFLINE_MESSAGE_NOT_FOUND` | No unsent message has that id (unknown, or already sent). | +| `ERROR_OFFLINE_DISABLED` | Offline support is not running. | +| `ERROR_OFFLINE_MESSAGE_PERSIST_FAILED` | The local cache was briefly unavailable. Try again. | + +These codes are available as `CometChat.OfflineErrorCode.MESSAGE_IN_FLIGHT`, `MESSAGE_NOT_FOUND`, `DISABLED` and `MESSAGE_PERSIST_FAILED`. + +## Auto-send on Reconnect + +By default, queued `PENDING` messages are sent automatically when the realtime connection is re-established. This is controlled by `OfflineConfig.autoSendPendingOnReconnect` (default `true`); see [Enable Offline Support](/sdk/react-native/offline-enable) and the [reference](/sdk/react-native/offline-reference). A queued message that keeps getting no answer is retried on each reconnect, up to 5 times, then becomes `FAILED` (`TRANSIENT`). With it off, nothing retries a queued message on reconnect — a send that gets no answer becomes `FAILED` (`TRANSIENT`), which you recover with `retryMessage`. + +Reconnect also runs a catch-up sync for received messages — see [Stay in Sync](/sdk/react-native/offline-sync). + +## Queue Limits + +The outbox holds at most `maxPendingMessages` messages still trying to send (`PENDING` + `SENDING`); `FAILED` messages do not count. When the queue is full, `OfflineConfig.pendingQueueOverflowPolicy` decides what happens: + +| `PendingMessageOverflowPolicy` | Behaviour on overflow | +|---|---| +| `REJECT_NEWEST` (**default**) | The new send is refused and reported with reason `QUEUE_FULL`. Nothing already queued is lost. | +| `DROP_OLDEST` | The oldest `PENDING` message is evicted to admit the new one, and reported with `onError` (code `ERROR_PENDING_QUEUE_LIMIT`, reason `QUEUE_OVERFLOW`). If there is no `PENDING` message to evict, the new send is refused with `QUEUE_FULL`. | + +A media send whose bytes do not fit in `maxMediaCacheSize`, even after cached received media is evicted, is refused with reason `BYTE_OVERFLOW`. Queued media is never evicted to make room. + +A **refused** send (`QUEUE_FULL` / `BYTE_OVERFLOW`) is never queued, so it fires **no** `onMessagePending`; it reports `onError` with the code `ERROR_PENDING_QUEUE_LIMIT`, and the global `OfflineMessageListener` receives `onOutboxMessageFailed(message, reason)` carrying the exact reason. + + +A refused or evicted message (`QUEUE_FULL`, `QUEUE_OVERFLOW`, `BYTE_OVERFLOW`) has **no durable row** — it does not appear in `getPendingMessages`, and calling `retryMessage` on its id rejects with `ERROR_OFFLINE_MESSAGE_NOT_FOUND`. Capture these from the failure callback and handle them at that moment. Prefer the default `REJECT_NEWEST`, which never silently drops an already-queued message from another conversation. + + +## Edits, Deletes, and Reactions Need a Connection + +Offline queuing applies to **sending new messages** only. Changes to existing messages — edit, delete, add or remove a reaction — and other own actions are **not queued offline**. They are sent to the server whenever you trigger them, and they reject with the not-connected error if the request gets no answer. Their effect reaches the cache only once the server accepts them. + +## Durable Media Uploads + +A multi-attachment upload survives an app restart, but it never resumes on its own. Fetch the existing request for a conversation with `getExistingUploadFileRequest` (a Promise that resolves `null` when there is none), and read each file's progress through `getItems()`, which returns a list of items, each with a `state` of `QUEUED`, `UPLOADING`, `UPLOADED`, `FAILED`, or `REJECTED` (which cannot be retried). Retry an individual file with `retryAttachment(fileId)`. With offline on, each item also carries `localCopyPath`, the SDK's copy of the bytes, for previews of a restored tray. + + + +```typescript +const request = await CometChat.getExistingUploadFileRequest("UID", CometChat.RECEIVER_TYPE.USER); + +request?.getItems().forEach((item) => { + console.log(item.fileId, "->", item.state); + if (item.state === "FAILED") { + request.retryAttachment(item.fileId); + } +}); +``` + + +```javascript +const request = await CometChat.getExistingUploadFileRequest("UID", CometChat.RECEIVER_TYPE.USER); + +request?.getItems().forEach((item) => { + console.log(item.fileId, "->", item.state); + if (item.state === "FAILED") { + request.retryAttachment(item.fileId); + } +}); +``` + + + +See [Media Caching](/sdk/react-native/offline-media-caching) for downloading and caching received attachments. + + +For the full list of offline methods, listeners, enums, model fields, and error codes, see the [Offline Configuration & API Reference](/sdk/react-native/offline-reference). + diff --git a/sdk/react-native/offline-sync.mdx b/sdk/react-native/offline-sync.mdx new file mode 100644 index 000000000..a25e5d8db --- /dev/null +++ b/sdk/react-native/offline-sync.mdx @@ -0,0 +1,325 @@ +--- +title: "Stay in Sync" +sidebarTitle: "Stay in Sync" +description: "Catch up on everything that changed while offline, observe sync progress, trigger a manual sync, and manage the local cache with the CometChat React Native Chat SDK." +--- + + + +| Field | Value | +| --- | --- | +| Observe sync | `CometChat.addSyncListener(id, listener)` / `removeSyncListener(id)` | +| Poll state | `CometChat.getSyncState()` → `IDLE` \| `SYNCING`; `await CometChat.getLastSyncTimestamp()` → server epoch seconds, `0` if never | +| Manual sync | `await CometChat.forceSyncNow()` — resolves when the sweep is accepted | +| Cache resets | `CometChat.addOfflineCacheResetListener(id, listener)` | +| Cache size / clear | `await CometChat.getCacheSize()` (bytes) · `await CometChat.clearOfflineCache()` (destructive) | +| Prerequisites | [Offline support enabled](/sdk/react-native/offline-enable) | + +```typescript +CometChat.addSyncListener("SYNC_LISTENER", { + onConversationSyncCompleted: (conversations) => { /* blind-replace list */ }, + onMessageSyncCompleted: (messages, outcome) => { /* merge by id */ }, + onMessageSyncError: (error) => {}, +}); +CometChat.removeSyncListener("SYNC_LISTENER"); + +const state = CometChat.getSyncState(); // IDLE | SYNCING +const lastSync: number = await CometChat.getLastSyncTimestamp(); + +await CometChat.forceSyncNow(); // rejects: IN_PROGRESS / COOLDOWN / DISABLED / NOT_LOGGED_IN + +CometChat.addOfflineCacheResetListener("RESET_LISTENER", { + onOfflineCacheReset: (reason, pendingMessagesLost) => {}, +}); + +const bytes: number = await CometChat.getCacheSize(); +await CometChat.clearOfflineCache(); // destructive; drops the un-sent outbox +``` + + + +When the device comes back online, the SDK runs a **catch-up sync**: it refreshes the conversation list, then replays everything that changed in your messages since the last sync, and merges the results into the local cache. This page covers how to observe that sync, trigger one on demand, and manage the cache it maintains. + + +Everything on this page is a no-op unless offline support is active for the logged-in user (`CometChat.isOfflineEnabled()` is `true`). See [Enable Offline Support](/sdk/react-native/offline-enable). The message catch-up depends on the server's `updatedAt` message changelog; against a server without it, each sweep returns nothing and missed edits, deletes, and reactions are not synced. + + +## How catch-up works + +A sync runs automatically when the connection settles after a reconnect (the connection must hold for about 2.5 seconds). It never runs on a brief connect-then-drop flap, and overlapping triggers coalesce: at most one follow-up sweep is queued behind the running one. The outbox is flushed first, and the sweep starts after the flush finishes or after 20 seconds, whichever comes first. The sweep proceeds in two phases, in order: + +1. **Conversation phase** — the conversation list is fetched as one fresh page. A conversation-list UI can blind-replace its list with the result. +2. **Message phase** — the SDK replays the message changelog from the last sync point. New messages and updates to already-cached messages are merged into the cache. + +The sync is incremental and safe to repeat: it only moves forward from the last successful point, and a failed or interrupted phase simply resumes on the next reconnect. The conversation refresh does remove conversations you are no longer part of, along with their cached messages, media, and any still-queued messages for them (which fail with reason `DISCARDED`). A refresh that fails, or whose response cannot be read, removes nothing. A single sweep request that gets no answer is aborted after 60 seconds and counts as a failed phase. A first-ever sync (cold start) seeds its starting point from the newest conversation and fetches no message history — older history is reached by scrolling online. + +## Listen for sync progress + +Register a `SyncListener` to observe the lifecycle of every automatic and manual sync. Each phase fires `started`, then exactly one of `completed` / `error`. Every callback is optional. + + + +```typescript +CometChat.addSyncListener("SYNC_LISTENER", { + onConversationSyncStarted: () => {}, + + onConversationSyncCompleted: (conversations: CometChat.Conversation[]) => { + // The full, fresh conversation page. Blind-replace your list. + }, + + onConversationSyncError: (error: CometChat.CometChatException) => {}, + + onMessageSyncStarted: () => {}, + + onMessageSyncCompleted: (messages: CometChat.BaseMessage[], outcome: CometChat.SyncOutcome) => { + // New + changed messages reconciled this sync. Merge by message id. + }, + + onMessageSyncError: (error: CometChat.CometChatException) => {}, +}); +``` + + +```javascript +CometChat.addSyncListener("SYNC_LISTENER", { + onConversationSyncStarted: () => {}, + + onConversationSyncCompleted: (conversations) => { + // The full, fresh conversation page. Blind-replace your list. + }, + + onConversationSyncError: (error) => {}, + + onMessageSyncStarted: () => {}, + + onMessageSyncCompleted: (messages, outcome) => { + // New + changed messages reconciled this sync. Merge by message id. + }, + + onMessageSyncError: (error) => {}, +}); +``` + + + +| Callback | When it fires | +|---|---| +| `onConversationSyncStarted()` | The conversation refresh began. | +| `onConversationSyncCompleted(conversations)` | The refresh succeeded; `conversations` is the full conversation page. | +| `onConversationSyncError(error)` | The refresh failed, or its response could not be read. Nothing is removed from the cache, and the refresh retries on the next reconnect. | +| `onMessageSyncStarted()` | The message changelog sweep began. Never fires on a cold start. | +| `onMessageSyncCompleted(messages, outcome)` | The sweep finished; `messages` carries the new and changed messages. | +| `onMessageSyncError(error)` | The sweep failed; it resumes on the next reconnect. | + +The two phases are independent — a conversation-phase error does not stop the message phase. A phase error is informational: the cache is never left in a wrong state and the sweep resumes automatically. + +The `SyncListener` is self-sufficient: `onMessageSyncCompleted` carries the swept messages, including `action` and `call` messages that the `MessageListener` does not deliver, so you can use it without a `MessageListener`. + +`onMessageSyncCompleted` reports the outcome: + +| `SyncOutcome` | Meaning | +|---|---| +| `CAUGHT_UP` | The changelog was fully replayed; the cache reflects every server change up to the sync point. | +| `PARTIAL` | A page was ingested and the sync point advanced, but a backlog remains. Each sync, automatic or from `forceSyncNow`, drains one page of up to 1000 changes. | + +Remove the listener with `CometChat.removeSyncListener("SYNC_LISTENER")`. + +## Receiving synced messages + +If you already use a `CometChat.MessageListener`, synced messages still arrive there, so you usually do not need a `SyncListener` to keep your chat up to date: + +- **Genuinely new** messages arrive one-by-one on `onTextMessageReceived` (and the other typed variants), with `getSource()` equal to `MessageSource.SYNC`. +- **Updates** to already-cached messages arrive together on `onMessagesSynced(messages)`. Upsert the batch by id; never append it. + + + +```typescript +CometChat.addMessageListener( + "MSG_LISTENER", + new CometChat.MessageListener({ + onTextMessageReceived: (message: CometChat.TextMessage) => { + if (message.getSource() === CometChat.MessageSource.SYNC) { + // Delivered by a catch-up sync. Suppress notification spam if you wish. + } + }, + onMessagesSynced: (messages: CometChat.BaseMessage[]) => { + // Updates to already-cached messages. Merge by id. + }, + }) +); +``` + + +```javascript +CometChat.addMessageListener( + "MSG_LISTENER", + new CometChat.MessageListener({ + onTextMessageReceived: (message) => { + if (message.getSource() === CometChat.MessageSource.SYNC) { + // Delivered by a catch-up sync. Suppress notification spam if you wish. + } + }, + onMessagesSynced: (messages) => { + // Updates to already-cached messages. Merge by id. + }, + }) +); +``` + + + + +On reconnect, queued messages are sent first, then the sync runs. `forceSyncNow` runs only the sync; it does not send queued messages (use `retryMessage` or `retryAllFailedMessages` for those). + + +## Check sync state + +Poll the current sync state and the time of the last completed sync at any time. + + + +```typescript +const state: CometChat.SyncState = CometChat.getSyncState(); // IDLE or SYNCING +const lastSync: number = await CometChat.getLastSyncTimestamp(); // server epoch seconds; 0 if never synced +``` + + +```javascript +const state = CometChat.getSyncState(); // IDLE or SYNCING +const lastSync = await CometChat.getLastSyncTimestamp(); // server epoch seconds; 0 if never synced +``` + + + +`getSyncState()` returns `SyncState.SYNCING` while a sweep is in flight, otherwise `SyncState.IDLE` (also when offline support is disabled). `getLastSyncTimestamp()` returns a **server-provided** epoch value in seconds, never the device clock, and `0` if the cache has never synced. + +## Trigger a manual sync + +Call `forceSyncNow` to run a catch-up immediately — for example behind a pull-to-refresh. It routes through the same throttle as the automatic sync, so it will not start a second overlapping sweep or hammer the backend; manual syncs must be at least 10 seconds apart. + +The returned Promise resolves when the sweep is **accepted**, not when it completes. The sync's actual progress and outcome arrive on your registered `SyncListener`. You can instead pass an optional `{ onSuccess, onError }` callback object. + + + +```typescript +CometChat.forceSyncNow().then( + () => { + // Accepted and started. + }, + (error: CometChat.CometChatException) => { + // Rejected — see the code. + console.log(error.code, error.details); + } +); +``` + + +```javascript +CometChat.forceSyncNow().then( + () => { + // Accepted and started. + }, + (error) => { + // Rejected — see the code. + console.log(error.code, error.details); + } +); +``` + + + +| Rejection code | Meaning | +|---|---| +| `ERROR_OFFLINE_SYNC_IN_PROGRESS` | A sync (automatic or manual) is already running. | +| `ERROR_OFFLINE_SYNC_COOLDOWN` | A sync ran too recently; `error.details.retryAfterMs` carries the remaining cooldown in milliseconds. | +| `ERROR_OFFLINE_DISABLED` | A user is logged in but offline support is not active. | +| `USER_NOT_LOGED_IN` | Nobody is logged in. The spelling is the SDK's existing code. | + +## When the cache resets + +In rare cases the SDK cannot keep the existing cache — for example the encryption key became unrecoverable, or the database file is corrupt. When this happens it rebuilds the cache from scratch and notifies an `OfflineCacheResetListener`. Any un-sent messages that were queued are lost in the rebuild; `pendingMessagesLost` reports how many, or `CometChat.OFFLINE_PENDING_COUNT_UNKNOWN` (`-1`) when they could not be counted — that value means "unknown", not "none". + + + +```typescript +CometChat.addOfflineCacheResetListener("RESET_LISTENER", { + onOfflineCacheReset: (reason: CometChat.OfflineCacheResetReason, pendingMessagesLost: number) => { + // The cache was rebuilt. Re-fetch anything you need; it will re-sync from the server. + }, +}); +``` + + +```javascript +CometChat.addOfflineCacheResetListener("RESET_LISTENER", { + onOfflineCacheReset: (reason, pendingMessagesLost) => { + // The cache was rebuilt. Re-fetch anything you need; it will re-sync from the server. + }, +}); +``` + + + +| `OfflineCacheResetReason` | Cause | +|---|---| +| `KEY_LOST` | The device key could not be recovered (for example cleared app data or a restored backup); a fresh key was minted. | +| `WRONG_KEY` | The database exists but does not decrypt under the current key. | +| `CORRUPTED` | The database file is corrupt on disk. | +| `ESCALATED` | An unclassified failure recurred across launches and was escalated to a permanent rebuild. | +| `ENCRYPTION_CHANGED` | The `encryption` setting changed, so the on-disk cache was rebuilt in the configured format. | + +On a relaunch with a saved login, the cache can be reset during startup. The SDK delivers that reset just after `await CometChat.init(...)` resolves. Add the cache-reset listener, and the [outbox listener](/sdk/react-native/offline-send#observe-the-queue-app-wide), straight after `init` resolves, before you await anything else, or you will miss it. + + + +```typescript +await CometChat.init("APP_ID", appSettings); + +CometChat.addOfflineCacheResetListener("RESET_LISTENER", { + onOfflineCacheReset: (reason, pendingMessagesLost) => {}, +}); +``` + + +```javascript +await CometChat.init("APP_ID", appSettings); + +CometChat.addOfflineCacheResetListener("RESET_LISTENER", { + onOfflineCacheReset: (reason, pendingMessagesLost) => {}, +}); +``` + + + +Remove the listener with `CometChat.removeOfflineCacheResetListener("RESET_LISTENER")`. See [Encrypt the Cache](/sdk/react-native/offline-encryption) for the encryption-related reasons. + +## Manage the cache + +Read the current cache size, or clear the cache entirely. + + + +```typescript +const bytes: number = await CometChat.getCacheSize(); // DB + media byte store, in bytes + +await CometChat.clearOfflineCache(); // empty the per-user cache in place +``` + + +```javascript +const bytes = await CometChat.getCacheSize(); // DB + media byte store, in bytes + +await CometChat.clearOfflineCache(); // empty the per-user cache in place +``` + + + +`getCacheSize()` returns the total cache size in bytes (local database plus the media byte store) as a single figure, or `0` when offline support is disabled. + + +`clearOfflineCache()` is destructive and irreversible. It empties the entire per-user cache — cached messages, conversations, media bytes **and the un-sent outbox** — while the store stays open. Each still-queued `PENDING` / `SENDING` message fails with reason `DISCARDED`; a message already on the wire waits for its answer and, if accepted, is reported sent. Messages already `FAILED` are removed silently. It is a no-op unless offline support is active. + + + +You never need to clear the cache on logout — the SDK does it for you. The cache is stored **per user**: it survives app restarts and a token refresh for the same logged-in user, and is wiped automatically on logout, when the server rejects the auth token, or when a different user or app ID is used. See [Offline Support](/sdk/react-native/offline-overview). + diff --git a/sdk/react-native/pin-conversation.mdx b/sdk/react-native/pin-conversation.mdx index a27cffc80..8652ca6d5 100644 --- a/sdk/react-native/pin-conversation.mdx +++ b/sdk/react-native/pin-conversation.mdx @@ -281,13 +281,7 @@ if (isPinned) { ## Keeping the List in Sync - - The React Native SDK does **not** expose a `ConversationListener`, and there - are no `onConversationPinned` / `onConversationUnpinned` callbacks. Conversation - pins made on another device do not arrive over the socket. - - -Update your list from the promise on the device that performed the action, and re-fetch the conversation list when the screen regains focus to pick up pins made elsewhere. +On the device that performed the action, update your list from the Promise. Your own pin or unpin does **not** fire `onConversationPinned` / `onConversationUnpinned` on that device. Those callbacks fire on the user's **other** devices when a pin arrives there, so register a `ConversationListener` to reorder the list without a refetch. @@ -317,6 +311,49 @@ CometChat.pinConversation("cometchat-uid-1", CometChat.RECEIVER_TYPE.USER).then( +Register the listener on every screen that shows the list, and remove it when the screen unmounts: + + + +```typescript +CometChat.addConversationListener( + "UNIQUE_LISTENER_ID", + new CometChat.ConversationListener({ + onConversationPinned: (conversation: CometChat.Conversation) => { + console.log("Conversation pinned:", conversation); + }, + onConversationUnpinned: (conversation: CometChat.Conversation) => { + console.log("Conversation unpinned:", conversation); + }, + }) +); + +CometChat.removeConversationListener("UNIQUE_LISTENER_ID"); +``` + + + + +```javascript +CometChat.addConversationListener( + "UNIQUE_LISTENER_ID", + new CometChat.ConversationListener({ + onConversationPinned: (conversation) => { + console.log("Conversation pinned:", conversation); + }, + onConversationUnpinned: (conversation) => { + console.log("Conversation unpinned:", conversation); + }, + }) +); + +CometChat.removeConversationListener("UNIQUE_LISTENER_ID"); +``` + + + + + ## Pin Limit @@ -376,6 +413,14 @@ CometChat.isPinConversationEnabled().then((enabled) => { Unlike the two message-level flags, this one is **not seeded anywhere and is off by default** — treat a `false` as the normal case for a new app, not as an error. +## Offline Behaviour + +With [offline support](/sdk/react-native/offline-cached-data) enabled, passing a `ConversationFetchListener` to `fetchNext()` on a `setPinnedBy()` request calls `onCachedConversations` first (even when the page is empty) and then `onServerConversations` with the live list. Offline, the read ends with `onError` (`FAILED_TO_FETCH`) after the cached page, and the Promise rejects. `onCachedConversations` holds the pinned conversations the cache already knows about, in the same order. + +| Builder method | Offline | +|---|---| +| `setPinnedBy` | Cached | + --- ## Next Steps diff --git a/sdk/react-native/pin-message.mdx b/sdk/react-native/pin-message.mdx index a5b713509..156f56c41 100644 --- a/sdk/react-native/pin-message.mdx +++ b/sdk/react-native/pin-message.mdx @@ -324,6 +324,8 @@ CometChat.addMessageListener( Each callback receives the **full updated message**, so `getPinnedAt()` and `getPinnedBy()` are readable without a follow-up fetch — replace the message in your list directly. +Your own pin or unpin does **not** fire `onMessagePinned` / `onMessageUnpinned` on the device that performed it. On that device, update your UI from the `pinMessage()` / `unpinMessage()` Promise. The callbacks fire on the user's other devices and on other participants' devices when a pin arrives there. + Remove the listener when the screen unmounts: @@ -406,6 +408,14 @@ CometChat.isPinMessageEnabled().then((enabled) => { This resolves `false` rather than rejecting when the flag is missing or settings are unavailable, so an unavailable setting degrades to "hidden" instead of an unhandled rejection. +## Offline Behaviour + +With [offline support](/sdk/react-native/offline-cached-data) enabled, passing a `MessageFetchListener` to `fetchPrevious()` or `fetchNext()` on a `setPinnedOnly(true)` request calls `onCachedMessages` first (even when the page is empty) and then `onServerMessages` with the live list. Offline, the read ends with `onError` (`FAILED_TO_FETCH`) after the cached page, and the Promise rejects. `onCachedMessages` holds the conversation's cached pinned messages, newest pin first. The pinned list is answered from its own store, so other `MessagesRequestBuilder` filters are ignored in the cached page. + +| Builder method | Offline | +|---|---| +| `setPinnedOnly` (with `setUID` / `setGUID`) | Cached | + --- ## Next Steps diff --git a/sdk/react-native/reactions.mdx b/sdk/react-native/reactions.mdx index c4d9242c9..55ad210ce 100644 --- a/sdk/react-native/reactions.mdx +++ b/sdk/react-native/reactions.mdx @@ -35,8 +35,8 @@ const reactions = await request.fetchNext(); // Listen for reaction events CometChat.addMessageListener("LISTENER_ID", new CometChat.MessageListener({ - onMessageReactionAdded: (reaction) => {}, - onMessageReactionRemoved: (reaction) => {} + onMessageReactionAdded: (reactionEvent) => {}, + onMessageReactionRemoved: (reactionEvent) => {} })); ``` @@ -164,13 +164,13 @@ reactions.forEach((reaction) => { ## Fetch Reactions for a Message -To get the full list of who reacted with what on a specific message, use `ReactionsRequestBuilder`. You can paginate through results with `fetchNext()` and `fetchPrevious()` (max 100 per request). +To get the full list of who reacted with what on a specific message, use `ReactionsRequestBuilder`. You can paginate through results with `fetchNext()` and `fetchPrevious()` (default 10, maximum 1000 per request). A larger limit is sent as 1000. | Builder Method | Description | | --- | --- | | `setMessageId(value)` | The message ID to fetch reactions for. Required. | | `setReaction(value)` | Filter to a specific emoji (e.g., `"😊"`). When set, only reactions matching this emoji are returned. | -| `setLimit(value)` | Number of reactions to fetch per request. Max 100. | +| `setLimit(value)` | Number of reactions to fetch per request. Default 10, maximum 1000. | ### Fetch Next @@ -186,7 +186,7 @@ let reactionsRequest = new CometChat.ReactionsRequestBuilder() .build(); reactionsRequest.fetchNext().then( - (reactions: CometChat.MessageReaction[]) => { + (reactions: CometChat.Reaction[]) => { console.log("list fetched:", reactions); }, (error: CometChat.CometChatException) => { @@ -232,7 +232,7 @@ let reactionsRequest = new CometChat.ReactionsRequestBuilder() .build(); reactionsRequest.fetchPrevious().then( - (reactions: CometChat.MessageReaction[]) => { + (reactions: CometChat.Reaction[]) => { console.log("list fetched:", reactions); }, (error: CometChat.CometChatException) => { @@ -264,21 +264,35 @@ reactionsRequest.fetchPrevious().then( +### Offline Behaviour + +With [offline support](/sdk/react-native/offline-cached-data) enabled, `ReactionsRequest.fetchNext()` and `fetchPrevious()` first deliver `onCachedReactions` from the device when you pass a `ReactionFetchListener` (empty when nothing was recorded), then `onServerReactions` with the live page to reconcile by reaction id. Use a separate `ReactionsRequest` for each direction. Offline, a read ends with `onError` with code `FAILED_TO_FETCH` after the cached page. + +| Builder method | Offline | +| --- | --- | +| `setLimit()` | Cached | +| `setMessageId()` | Cached | +| `setReaction()` | Cached | + +The cached phase replays the page this request last received from the server online, in the same direction. It does not re-run these settings as a query against the cache. + ## Real-Time Reaction Events Register a `MessageListener` to receive reaction events as they happen. This is how you keep your UI in sync when other users add or remove reactions. +Override `onMessageReactionAdded` and `onMessageReactionRemoved`. Each receives a `ReactionEvent`, whose `getReaction()` returns the `Reaction` and whose `getReceiverId()`, `getReceiverType()`, `getConversationId()` and `getParentMessageId()` identify where it happened. + ```typescript let listenerID:string = "UNIQUE_LISTENER_ID"; CometChat.addMessageListener(listenerID, new CometChat.MessageListener({ - onMessageReactionAdded:(message: Object) => { - console.log("Reaction added", message); + onMessageReactionAdded:(reactionEvent: CometChat.ReactionEvent) => { + console.log("Reaction added", reactionEvent.getReaction().getReaction()); }, - onMessageReactionRemoved:(message: Object) => { - console.log("Reaction removed", message); + onMessageReactionRemoved:(reactionEvent: CometChat.ReactionEvent) => { + console.log("Reaction removed", reactionEvent.getReaction().getReaction()); } })) ``` @@ -289,11 +303,11 @@ CometChat.addMessageListener(listenerID, new CometChat.MessageListener({ let listenerID = "UNIQUE_LISTENER_ID"; CometChat.addMessageListener(listenerID, new CometChat.MessageListener({ - onMessageReactionAdded:(message) => { - console.log("Reaction added", message); + onMessageReactionAdded:(reactionEvent) => { + console.log("Reaction added", reactionEvent.getReaction().getReaction()); }, - onMessageReactionRemoved:(message) => { - console.log("Reaction removed", message); + onMessageReactionRemoved:(reactionEvent) => { + console.log("Reaction removed", reactionEvent.getReaction().getReaction()); } })) ``` @@ -302,6 +316,8 @@ CometChat.addMessageListener(listenerID, new CometChat.MessageListener({ ### Remove the Listener +Remove the listener with the same listener ID: + ```typescript @@ -326,11 +342,9 @@ Always remove listeners when they're no longer needed (e.g., on component unmoun ## Update Message With Reaction Info -When a user adds or removes a reaction, you will receive a real-time event. Once you receive the real time event you would want to update the message with the latest reaction information. To do so you can use the `updateMessageWithReactionInfo()` method. - -The `updateMessageWithReactionInfo()` method provides a seamless way to update the reactions on a message instance (`BaseMessage`) in real-time. This method ensures that when a reaction is added or removed from a message, the BaseMessage object's `getReactions()` property reflects this change immediately. +When a user adds or removes a reaction, you will receive a real-time event. Once you receive it, you would want to update the message with the latest reaction information. To do so, use `CometChat.CometChatHelper.updateMessageWithReactionInfo()`. -When you receive a real-time reaction event (`MessageReaction`), call the `updateMessageWithReactionInfo()` method, passing the BaseMessage instance (`message`), event data (`MessageReaction`) and reaction event action type (`CometChat.REACTION_ACTION.REACTION_ADDED` or `CometChat.REACTION_ACTION.REACTION_REMOVED`) that corresponds to the message being reacted to. +Pass the `BaseMessage` the reaction belongs to, the `Reaction` from the event (`reactionEvent.getReaction()`), and the action: `CometChat.REACTION_ACTION.REACTION_ADDED` from `onMessageReactionAdded` or `CometChat.REACTION_ACTION.REACTION_REMOVED` from `onMessageReactionRemoved`. It returns the message with its `getReactions()` updated. @@ -338,10 +352,10 @@ When you receive a real-time reaction event (`MessageReaction`), call the `updat // The message to which the reaction is related let message: CometChat.BaseMessage = ...; -// The reaction event data received in real-time -let messageReaction: CometChat.MessageReaction = ...; +// The reaction from the real-time ReactionEvent +let messageReaction: CometChat.Reaction = reactionEvent.getReaction(); -// The received reaction event real-time action type +// CometChat.REACTION_ACTION.REACTION_ADDED or CometChat.REACTION_ACTION.REACTION_REMOVED let action: CometChat.REACTION_ACTION = CometChat.REACTION_ACTION.REACTION_ADDED; let modifiedBaseMessage = CometChat.CometChatHelper.updateMessageWithReactionInfo( @@ -357,10 +371,10 @@ action // The message to which the reaction is related let message = ...; -// The reaction event data received in real-time -let messageReaction = ...; +// The reaction from the real-time ReactionEvent +let messageReaction = reactionEvent.getReaction(); -// The received reaction event real-time action type +// CometChat.REACTION_ACTION.REACTION_ADDED or CometChat.REACTION_ACTION.REACTION_REMOVED let action = CometChat.REACTION_ACTION.REACTION_ADDED; let modifiedBaseMessage = CometChat.CometChatHelper.updateMessageWithReactionInfo( @@ -377,7 +391,7 @@ On success, this method returns a [`BaseMessage`](/sdk/reference/messages#baseme | Parameter | Type | Description | |-----------|------|-------------| | `message` | `BaseMessage` | The message object to update | -| `messageReaction` | `MessageReaction` | The reaction event received from the listener | +| `messageReaction` | `Reaction` | The reaction from the `ReactionEvent` received by the listener | | `action` | `REACTION_ACTION` | `CometChat.REACTION_ACTION.REACTION_ADDED` or `CometChat.REACTION_ACTION.REACTION_REMOVED` | --- diff --git a/sdk/react-native/real-time-listeners.mdx b/sdk/react-native/real-time-listeners.mdx index d1d8786e4..b340415ee 100644 --- a/sdk/react-native/real-time-listeners.mdx +++ b/sdk/react-native/real-time-listeners.mdx @@ -73,6 +73,8 @@ CometChat provides 4 listeners viz. 3. [Message Listener](/sdk/react-native/real-time-listeners#message-listener) 4. [Call Listener](/sdk/react-native/real-time-listeners#call-listener) +With offline support enabled, four more listeners report what the SDK does on the device; see [Offline listeners](#offline-listeners). + ## User Listener Receive online/offline presence events for users. @@ -349,6 +351,10 @@ CometChat.removeGroupListener(listenerID); Receive events for incoming messages, typing indicators, read/delivery receipts, message edits/deletes, reactions, and interactive messages. + +With offline support enabled, `MessageListener` also receives `onMessagesSynced` after the SDK catches up on reconnect. It carries only updates to already-cached messages that changed while offline (reactions, edits, deletes and receipts). Messages that arrived while offline still come through the typed callbacks such as `onTextMessageReceived`. See [Stay in Sync](/sdk/react-native/offline-sync). + + | Method | Information | | ----------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | | **onTextMessageReceived(message: [CometChat.TextMessage](/sdk/reference/messages#textmessage))** | This event is triggered when a Text Message is received. | @@ -543,13 +549,12 @@ CometChat.removeMessageListener(listenerID); [Save A Message](/sdk/react-native/save-message). - - **Conversation pins have no listener.** The React Native SDK exposes no - `ConversationListener` and no `onConversationPinned` / - `onConversationUnpinned` callbacks — unlike the JavaScript SDK. Update your - list from the `pinConversation()` promise and re-fetch on focus. See + + Conversation pins use a separate `ConversationListener`, with + `onConversationPinned` and `onConversationUnpinned`. It fires on the user's + other devices and for admin pins, not on the device that pinned. See [Pin A Conversation](/sdk/react-native/pin-conversation). - + ## Call Listener @@ -634,6 +639,17 @@ CometChat.removeCallListener(listenerID); +## Offline listeners + +With offline support enabled (SDK 4.2.0 or newer), these listeners report what the SDK does on the device rather than events from the server. Each is documented with examples on its own page. + +| Listener | What it reports | Details | +|---|---|---| +| `OfflineMessageListener` | Every outbox transition, app-wide: a message queued, sent or failed | [Send While Offline](/sdk/react-native/offline-send) | +| `SyncListener` | Progress and outcome of each catch-up sync | [Stay in Sync](/sdk/react-native/offline-sync) | +| `OfflineCacheResetListener` | The on-device cache was rebuilt, for example after the encryption key was lost | [Encrypt the Cache](/sdk/react-native/offline-encryption) | +| `MediaDownloadListener` | A background media download finished or failed | [Media Caching](/sdk/react-native/offline-media-caching) | + --- ## Next Steps diff --git a/sdk/react-native/receive-messages.mdx b/sdk/react-native/receive-messages.mdx index 2a34bcb79..274cf208d 100644 --- a/sdk/react-native/receive-messages.mdx +++ b/sdk/react-native/receive-messages.mdx @@ -134,6 +134,10 @@ CometChat.addMessageListener( For the full `CardMessage` field reference, see [Campaigns → Card Messages](/sdk/react-native/campaigns#card-messages). + +With offline support enabled, a registered `MessageListener` also receives `onMessagesSynced(messages)` after the SDK catches up on reconnect. It carries only updates to already-cached messages that changed while offline: reactions, edits, deletes and receipts. Messages that arrived while offline still come one at a time through the usual `onTextMessageReceived`, `onMediaMessageReceived` and other typed callbacks. See [Stay in Sync](/sdk/react-native/offline-sync). + + ## Missed Messages Fetch messages that were sent while the user was offline using `getLastDeliveredMessageId()` and `fetchNext()`. diff --git a/sdk/react-native/retrieve-conversations.mdx b/sdk/react-native/retrieve-conversations.mdx index e07540a5f..061b3db8a 100644 --- a/sdk/react-native/retrieve-conversations.mdx +++ b/sdk/react-native/retrieve-conversations.mdx @@ -367,6 +367,8 @@ Relevant fields to access on returned conversations: Use `setSearchKeyword()` to search conversations by user or group name. +With offline support on, this filter is answered by the server only; the cached page is empty. + This feature is only available with `Conversation & Advanced Search`. The `Conversation & Advanced Search` is only available in `Advanced` & `Custom` [plans](https://www.cometchat.com/pricing). If you're already on one of these plans, please enable the `Conversation & Advanced Search` from [CometChat Dashboard](https://app.cometchat.com) (Open your app, navigate to Chats -> Settings -> General Configuration) @@ -659,6 +661,8 @@ When the conversation is fetched successfully, the response will return a single The `getConversation()` method returns a single [`Conversation`](/sdk/reference/entities#conversation) object. +To read the cached copy first, pass a `ConversationDetailsListener` as the third argument. With offline support on, `onCachedConversation` delivers the cached copy only when there is one, then `onServerConversation` delivers the live copy. Offline, the read then ends with `onError` (`FAILED_TO_FETCH`); when nothing is cached, `onError` is the only callback. Requires SDK 4.2.0. See [Show Cached Data](/sdk/react-native/offline-cached-data). + ## Convert Messages to Conversations Use `CometChatHelper.getConversationFromMessage()` to convert a received message into a [`Conversation`](/sdk/reference/entities#conversation) object. Useful for updating your Recent Chats list when receiving real-time messages. @@ -700,6 +704,24 @@ When converting a message to a conversation, `unreadMessageCount` and `tags` won The `getConversationFromMessage()` method returns a [`Conversation`](/sdk/reference/entities#conversation) object. +## Offline Behaviour + +With [offline support](/sdk/react-native/offline-cached-data) enabled, passing a `ConversationFetchListener` to `fetchNext()` calls `onCachedConversations` first (even when the page is empty) and then `onServerConversations` with the live list. Offline, the read ends with `onError` (`FAILED_TO_FETCH`) after the cached page, and the Promise rejects. The cached page is answered only when every builder filter on the request is available on the device. A single server-only filter leaves the cached page empty while the server phase still answers in full. + +| Builder method | Offline | +|---|---| +| `setConversationType` | Cached | +| `setTags` | Cached | +| `setUserTags` / `setGroupTags` | Cached | +| `setUnread` | Cached (last-known count) | +| `setIncludeBlockedUsers` / `setWithBlockedInfo` | Cached | +| `withUserAndGroupTags` / `withTags` | Cached | +| `setHideAgentic` / `setOnlyAgentic` | Cached | +| `setPinnedBy` | Cached | +| `setSearchKeyword` | Server only, cached page is empty | + +See [Show Cached Data](/sdk/react-native/offline-cached-data). + --- ## Next Steps diff --git a/sdk/react-native/retrieve-group-members.mdx b/sdk/react-native/retrieve-group-members.mdx index 8970f4824..79086e477 100644 --- a/sdk/react-native/retrieve-group-members.mdx +++ b/sdk/react-native/retrieve-group-members.mdx @@ -60,6 +60,7 @@ let groupMembersRequest = new CometChat.GroupMembersRequestBuilder(GUID) ### Set Search Keyword +With offline support on, this filter is answered by the server only; the cached page is empty. Filters members by a search string. @@ -130,6 +131,8 @@ Relevant fields to access on returned members: ### Set Status +With offline support on, this filter is answered by the server only; the cached page is empty. + Filters members by online status: | Value | Description | @@ -211,6 +214,17 @@ groupMembers => { The `fetchNext()` method returns an array of [`GroupMember`](/sdk/reference/entities#groupmember) objects. `GroupMember` extends [`User`](/sdk/reference/entities#user) and adds group-specific fields. +## Offline Behaviour + +With [offline support](/sdk/react-native/offline-cached-data) enabled, passing a `GroupMemberFetchListener` to `GroupMembersRequest.fetchNext()` calls `onCachedGroupMembers` first with the pages the server last returned for this exact request, replayed from the device (empty when nothing was recorded). Then `onServerGroupMembers` delivers the live page to reconcile by `uid`. Offline, the read ends with `onError` (`FAILED_TO_FETCH`) after the cached page, and the Promise rejects. A request that uses a server-only filter records nothing, so its cached page is always empty. See [Show Cached Data](/sdk/react-native/offline-cached-data). + +| Builder method | Offline | +|---|---| +| `setLimit` | Cached | +| `setScopes` | Cached | +| `setSearchKeyword` | Server only, cached page is empty | +| `setStatus` | Server only, cached page is empty | + --- ## Next Steps diff --git a/sdk/react-native/retrieve-groups.mdx b/sdk/react-native/retrieve-groups.mdx index 589a4347f..9ba7625b3 100644 --- a/sdk/react-native/retrieve-groups.mdx +++ b/sdk/react-native/retrieve-groups.mdx @@ -76,6 +76,7 @@ let groupsRequest = new CometChat.GroupsRequestBuilder() ### Set Search Keyword +With offline support on, this filter is answered by the server only; the cached page is empty. Filters groups by a search string. @@ -325,6 +326,18 @@ Returns an object with GUIDs as keys and online member counts as values. `getOnlineGroupMemberCount()` resolves with a `{ guid: count }` object where each key is a group GUID and its value is the number of currently online members in that group. +## Offline Behaviour + +With [offline support](/sdk/react-native/offline-cached-data) enabled, passing a `GroupFetchListener` to `GroupsRequest.fetchNext()` calls `onCachedGroups` first with the pages the server last returned for this exact request, replayed from the device (empty when nothing was recorded). Then `onServerGroups` delivers the live page to reconcile by `guid`. Offline, the read ends with `onError` (`FAILED_TO_FETCH`) after the cached page, and the Promise rejects. A request that uses a server-only filter records nothing, so its cached page is always empty. See [Show Cached Data](/sdk/react-native/offline-cached-data). + +| Builder method | Offline | +|---|---| +| `setLimit` | Cached | +| `joinedOnly` | Cached | +| `setTags` | Cached | +| `withTags` | Cached | +| `setSearchKeyword` | Server only, cached page is empty | + --- ## Next Steps diff --git a/sdk/react-native/retrieve-users.mdx b/sdk/react-native/retrieve-users.mdx index c18793625..c3e600a53 100644 --- a/sdk/react-native/retrieve-users.mdx +++ b/sdk/react-native/retrieve-users.mdx @@ -126,6 +126,8 @@ let usersRequest = new CometChat.UsersRequestBuilder() Filters users by a search string. +With offline support on, this filter is answered by the server only; the cached page is empty. + @@ -192,6 +194,8 @@ let usersRequest = new CometChat.UsersRequestBuilder() ### Set Status +With offline support on, this filter is answered by the server only; the cached page is empty. + Filters users by online status: - `CometChat.USER_STATUS.ONLINE` — Only online users @@ -601,6 +605,25 @@ userCount => { Returns the total online user count as a number. +## Offline Behaviour + +With [offline support](/sdk/react-native/offline-cached-data) enabled, passing a `UserFetchListener` to `UsersRequest.fetchNext()` calls `onCachedUsers` first with the pages the server last returned for this exact request, replayed from the device (empty when nothing was recorded). Then `onServerUsers` delivers the live page to reconcile by `uid`. Offline, the read ends with `onError` (`FAILED_TO_FETCH`) after the cached page, and the Promise rejects. A request that uses a server-only filter records nothing, so its cached page is always empty. See [Show Cached Data](/sdk/react-native/offline-cached-data). + +| Builder method | Offline | +|---|---| +| `setLimit` | Cached | +| `setSearchKeyword` | Server only, cached page is empty | +| `searchIn` | Used only together with `setSearchKeyword`, so server only | +| `setStatus` | Server only, cached page is empty | +| `hideBlockedUsers` | Cached | +| `setRole` / `setRoles` | Cached | +| `friendsOnly` | Cached | +| `setTags` | Cached | +| `withTags` | Cached | +| `setUIDs` | Cached | +| `sortBy` | Cached | +| `sortByOrder` | Cached | + --- ## Next Steps diff --git a/sdk/react-native/save-message.mdx b/sdk/react-native/save-message.mdx index 558dfe91b..2cccea686 100644 --- a/sdk/react-native/save-message.mdx +++ b/sdk/react-native/save-message.mdx @@ -362,6 +362,14 @@ CometChat.isSaveMessageEnabled().then((enabled) => { This resolves `false` rather than rejecting when the flag is missing or settings are unavailable. +## Offline Behaviour + +With [offline support](/sdk/react-native/offline-cached-data) enabled, passing a `MessageFetchListener` to `fetchPrevious()` or `fetchNext()` on a `setSavedOnly(true)` request calls `onCachedMessages` first (even when the page is empty) and then `onServerMessages` with the live list. Offline, the read ends with `onError` (`FAILED_TO_FETCH`) after the cached page, and the Promise rejects. `onCachedMessages` holds the user's cached saved messages across all conversations. The saved list is answered from its own store, so other `MessagesRequestBuilder` filters are ignored in the cached page. + +| Builder method | Offline | +|---|---| +| `setSavedOnly` | Cached | + --- ## Next Steps diff --git a/sdk/react-native/send-message.mdx b/sdk/react-native/send-message.mdx index 19719eb43..86b016ced 100644 --- a/sdk/react-native/send-message.mdx +++ b/sdk/react-native/send-message.mdx @@ -155,10 +155,18 @@ The [`TextMessage`](/sdk/reference/messages#textmessage) class constructor takes On success, `sendMessage()` returns a [`TextMessage`](/sdk/reference/messages#textmessage) | [`MediaMessage`](/sdk/reference/messages#mediamessage) | [`CustomMessage`](/sdk/reference/messages#custommessage) | [`BaseMessage`](/sdk/reference/messages#basemessage) object containing all information related to the sent message. + +With offline support enabled, pass a `MessageSentListener` as the second argument to queue the message when the device has no connection. `onMessagePending` fires first, on every send. Offline, `onError` can then fire once as a notice that the message is queued, not failed: check the `reason` argument (`null` means queued, a value means a real failure). `onMessageSent` follows when the message is sent on reconnect. A send without a listener is never queued and rejects while offline. See [Send While Offline](/sdk/react-native/offline-send). + + ## Media Message Send images, videos, audio, or files using `CometChat.sendMediaMessage()`. + +`sendMediaMessage()` accepts the same optional `MessageSentListener` as `sendMessage()`. Without a listener, a media message is not queued while offline. See [Send While Offline](/sdk/react-native/offline-send). + + There are two ways to send media messages: 1. **Upload a file** — Pass a file object and CometChat uploads it automatically 2. **Send a URL** — Provide a URL to media hosted on your server or cloud storage @@ -732,6 +740,10 @@ CometChat.sendMediaMessage(mediaMessage).then( Send structured data that doesn't fit text or media categories — like location coordinates, polls, or game moves. + +`sendCustomMessage()` accepts the same optional `MessageSentListener` as `sendMessage()`. Without a listener, a custom message is not queued while offline. See [Send While Offline](/sdk/react-native/offline-send). + + ```typescript diff --git a/sdk/react-native/thread-subscription.mdx b/sdk/react-native/thread-subscription.mdx index f63f1ced7..127b2c1a5 100644 --- a/sdk/react-native/thread-subscription.mdx +++ b/sdk/react-native/thread-subscription.mdx @@ -40,9 +40,9 @@ The server subscribes a user to a thread automatically when they start it, reply ## How state works -The SDK keeps **no subscription state of its own**. There is no cache and no listener to reconcile: +The SDK keeps **no subscription state of its own** beyond the flag on each message, and there is no listener to reconcile: -- Every message fetch asks the server for the flag, and it arrives on the message — read it with `message.isThreadSubscribed()`. +- Every message fetch asks the server for the flag, and it arrives on the message — read it with `message.isThreadSubscribed()`. With [offline support](/sdk/react-native/offline-cached-data) on, the flag is also cached with the message, so a cached read returns the last known value and the server read then updates it. - `subscribeToThread()` and `unsubscribeFromThread()` resolve when the server has accepted the change. The resolved promise **is** the acknowledgement. Your app owns the resulting UI state. That means you decide when to flip a toggle optimistically, and you decide what a thread's state is before you have fetched it. @@ -177,7 +177,7 @@ This only holds for a message you sent and obtained from a fetch. On anyone else ### Keeping your own copies in sync -The same thread can be represented by several message objects at once — a row in the message list, the header of an open thread view, an entry in a thread inbox. Because the SDK caches nothing, use `setThreadSubscribed()` to align the copies you hold once you know the answer: +The same thread can be represented by several message objects at once — a row in the message list, the header of an open thread view, an entry in a thread inbox. Because each copy carries its own flag, use `setThreadSubscribed()` to align the copies you hold once you know the answer: @@ -327,6 +327,15 @@ Each row is a `MessageThread` — enough to render an inbox without fetching the Treat `null` as "no badge" rather than as zero unread. +## Offline Behaviour + +With [offline support](/sdk/react-native/offline-cached-data) enabled, `ThreadsRequest.fetchNext()` delivers a cached page first when you pass a `ThreadFetchListener` (`onCachedThreads`, even when empty) and then the server list (`onServerThreads`). Offline, it ends with `onError` with code `FAILED_TO_FETCH` after the cached page. With `setParticipatedByMe(true)`, `onCachedThreads` holds the user's cached participated threads, in the same sort order. With `false`, it replays the last page fetched online for this request. `setUid()` / `setGuid()` scope that list to one conversation. + +| Builder method | Offline | +| --- | --- | +| `setParticipatedByMe()` | Cached (`true`: your participated threads; `false`: the last page fetched online) | +| `setUid()` / `setGuid()` | Cached (scope) | + --- ## Next Steps diff --git a/sdk/react-native/threaded-messages.mdx b/sdk/react-native/threaded-messages.mdx index 4acb7da77..99a18a3a2 100644 --- a/sdk/react-native/threaded-messages.mdx +++ b/sdk/react-native/threaded-messages.mdx @@ -236,6 +236,17 @@ let messagesRequest = new CometChat.MessagesRequestBuilder() `withParent(true)` works on any message fetch, including the pinned and saved lists. +## Offline Behaviour + +With [offline support](/sdk/react-native/offline-cached-data) enabled, thread fetches run in two phases when you pass a `MessageFetchListener` to `fetchPrevious()` or `fetchNext()`. `onCachedMessages` delivers the cached thread page first (even when empty), then `onServerMessages` delivers the reconciled server copy. Offline, the fetch ends with `onError` with code `FAILED_TO_FETCH` after the cached page. + +A thread read is answered from the cache even when both `setUID()` and `setGUID()` are set. Unlike a non-thread read, that pairing is not server-only here. + +| Builder method | Offline behaviour | +| --- | --- | +| `setParentMessageId()` | Cached | +| `hideReplies()` | Cached | + ## Subscribe to a Thread Users can follow a thread to be notified of future replies, or unfollow it to mute the noise. The server subscribes them automatically when they start a thread, reply in it, or are @-mentioned in it, and they can subscribe explicitly to any parent message. Read the current state off the parent message with `isThreadSubscribed()`. diff --git a/sdk/react-native/upload-files.mdx b/sdk/react-native/upload-files.mdx index 3ac362be5..4db5c4269 100644 --- a/sdk/react-native/upload-files.mdx +++ b/sdk/react-native/upload-files.mdx @@ -14,6 +14,10 @@ A request object is scoped to **one destination** (`receiverId` / `receiverType` The classic path (passing a file straight to the `MediaMessage` constructor) uploads and sends in one blocking call — you get no progress, no per-file remove, and no per-file retry. An `UploadFileRequest` moves the upload out of the send call so you can drive a rich composer UI, then send instantly because the files are already hosted. + +With offline support enabled, an upload batch and its files are saved on the device, so an in-progress batch survives an app restart. Uploads never resume on their own: fetch the batch with `CometChat.getExistingUploadFileRequest()` and call `retryAttachment(fileId)` for each file that has not uploaded. A media message you send with a `MessageSentListener` while offline is queued and sent on reconnect; `onError` can then be a notice that it is queued, not failed. See [Send While Offline](/sdk/react-native/offline-send). + + ## The upload-then-send flow @@ -237,6 +241,10 @@ All active upload batches are also cleared automatically on [`CometChat.logout() Once your files are uploaded, collect their `Attachment`s (from `request.getAttachments()` or from `result.successful` in `onComplete`), set them on a `MediaMessage`, and send. One batch can go out as a single multi-attachment message, or you can split the attachments across several messages — e.g. one message per media type using `getAttachmentsByType()`. + +To queue the message while offline, pass a `MessageSentListener` to `sendMediaMessage()`. Without one, the send goes straight to the server and rejects while offline. See [Send While Offline](/sdk/react-native/offline-send). + + ```typescript import { CometChat } from "@cometchat/chat-sdk-react-native";