diff --git a/docs.json b/docs.json index 96952f214..ae74cb471 100644 --- a/docs.json +++ b/docs.json @@ -1081,6 +1081,7 @@ "group": "Chat", "pages": [ "ui-kit/react-native/core-features", + "ui-kit/react-native/offline-support", "ui-kit/react-native/extensions", "ui-kit/react-native/ai-features" ] @@ -3485,7 +3486,8 @@ "sdk/react-native/transient-messages", "sdk/react-native/delivery-read-receipts", "sdk/react-native/mentions", - "sdk/react-native/reactions" + "sdk/react-native/reactions", + "sdk/react-native/offline-support" ] }, "sdk/react-native/calling-overview", diff --git a/sdk/react-native/llms-react-native-v4.mdx b/sdk/react-native/llms-react-native-v4.mdx index cb7a843d8..b76a44026 100644 --- a/sdk/react-native/llms-react-native-v4.mdx +++ b/sdk/react-native/llms-react-native-v4.mdx @@ -74,6 +74,7 @@ listeners. - [Delivery & Read Receipts](/sdk/react-native/delivery-read-receipts) - [Mentions](/sdk/react-native/mentions) - [Reactions](/sdk/react-native/reactions) +- [Offline Support](/sdk/react-native/offline-support) ## Calling - [Calling — Overview](/sdk/react-native/calling-overview) diff --git a/sdk/react-native/offline-support.mdx b/sdk/react-native/offline-support.mdx new file mode 100644 index 000000000..5cefe9f07 --- /dev/null +++ b/sdk/react-native/offline-support.mdx @@ -0,0 +1,826 @@ +--- +title: "Offline Support" +sidebarTitle: "Offline Support" +description: "Cache conversations and messages on the device, show them instantly, and queue messages sent without a connection with the CometChat React Native SDK." +--- + + + +| Field | Value | +| --- | --- | +| Package | `@cometchat/chat-sdk-react-native` | +| Import | `import { CometChat } from "@cometchat/chat-sdk-react-native";` | +| Requirements | React Native 0.74+ · `@op-engineering/op-sqlite` 9.0.0+ (required — offline stays off without it) · `react-native-fs` (media caching) · `react-native-keychain` (encryption only). Native modules — Expo needs a development build or EAS build; Expo Go is not supported | +| Enable | `new CometChat.AppSettingsBuilder().setOfflineConfig(new CometChat.OfflineConfig({ enabled: true, ... }))` before `CometChat.init()` — offline support is **off by default**; always set `enabled: true` explicitly | +| Minimum version | `@cometchat/chat-sdk-react-native` 4.2.0 | +| Key methods | `CometChat.isOfflineEnabled()` · `CometChat.getPendingMessages()` · `CometChat.retryMessage(localMessageId)` / `CometChat.retryAllFailedMessages()` · `CometChat.addSyncListener(id, listener)` / `CometChat.removeSyncListener(id)` · `CometChat.forceSyncNow()` · `CometChat.clearOfflineCache()` · `CometChat.getMediaCachingMode()` · `CometChat.getOfflineEncryptionStatus()` · `CometChat.addOfflineCacheResetListener(id, listener)` | +| Two-phase reads | Pass a plain-object listener to `fetchPrevious()` / `fetchNext()` — e.g. `{ onCachedMessages, onServerMessages, onError }`; `CometChat.getConversation(with, type, { onCachedConversation, onServerConversation, onError })` | +| Queued sends | Pass `{ onMessagePending, onMessageSent, onError }` as the second argument of `sendMessage()` / `sendMediaMessage()` / `sendCustomMessage()` | +| Offline send result | `onMessagePending` (status `PENDING`, `getLocalMessageId()` set), then one `onError(error, reason)` with `reason == null` and `error.errorParams.queued === true`; the Promise rejects with the same error. The message stays queued → `onMessageSent` after reconnect. Not a failure — don't resend. A send **without** a listener is not queued | +| Failed sends | `onError(error, reason)` with a `CometChat.MessageFailureReason`. A `FAILED` message stays in the outbox; the SDK never retries it on its own — call `retryMessage()` (only `FAILED` messages) or `retryAllFailedMessages()` (`TRANSIENT` only). `DISCARDED` is final | +| Outbox refusals | Outbox full or media too large: `onError` with code `ERROR_PENDING_QUEUE_LIMIT` and reason `QUEUE_FULL`, `QUEUE_OVERFLOW` or `BYTE_OVERFLOW` | +| Storage | SQLite database per app ID and user. Kept when the same user logs in again; deleted on logout, on login as a different user and on a failed login | +| Constraints | A request that uses a server-only filter such as `setSearchKeyword()` gets an empty cache phase, then the server result. Encryption is opt-in and covers the database only, not cached media | +| Related | [Send Messages](/sdk/react-native/send-message) · [Receive Messages](/sdk/react-native/receive-messages) · [Retrieve Conversations](/sdk/react-native/retrieve-conversations) · [Connection Status](/sdk/react-native/connection-status) | + + + +Offline support keeps a local copy of the logged-in user's conversations and messages on the device. Your app can render that copy the moment a screen opens — with or without a network — and the SDK replaces it with fresh server data as soon as it arrives. Messages sent while the device is offline are saved in a queue (the outbox) and sent automatically when the connection returns. + +Offline support is **opt-in**. Until you enable it, the SDK behaves exactly as it does today: nothing is stored on the device and every read goes to the server. + + + Offline support is available from React Native SDK **v4.2.0**. + + +## How It Works + +- **One local store per user.** The SDK keeps a separate SQLite database for each app ID and user. Logging in again as the same user keeps it; logging out, or logging in as a different user, deletes the previous user's data. +- **Cached, then server.** When you pass a listener to a read such as `fetchPrevious()`, the SDK first hands you what it has cached, then the server's result. Every server result is written back to the cache. +- **Queued sends.** A message sent with a `MessageSentListener` is stored in the outbox first. If the device is offline, it stays queued — even if the app is closed — and is sent automatically once the connection is back. +- **Catch-up sync.** After a reconnect, the SDK sends queued messages, then refreshes the cached conversations and messages so changes made while you were offline (new, edited and deleted messages) reach the cache. +- **The server wins.** If the cached copy and the server copy of a message differ, the server copy is kept. + +## Install the Dependencies + +Offline support uses three native modules. They are optional peer dependencies of the SDK, so the SDK installs and runs without them — install the ones you need in your app: + +| Package | Version | Needed for | +| --- | --- | --- | +| [`@op-engineering/op-sqlite`](https://github.com/OP-Engineering/op-sqlite) | 9.0.0 or later | **Required.** The local database. | +| [`react-native-fs`](https://github.com/itinance/react-native-fs) | Any | Caching media files on the device. | +| [`react-native-keychain`](https://github.com/oblador/react-native-keychain) | Any | [Encrypting the cache](#encrypt-the-cache) only. | + +Offline support needs **React Native 0.74 or later**, because current versions of `@op-engineering/op-sqlite` require it. + +```bash +npm install @op-engineering/op-sqlite react-native-fs +cd ios && pod install +``` + +Rebuild the app after installing — a JavaScript reload is not enough for native modules. + + + Without `@op-engineering/op-sqlite`, offline support stays off and the SDK + works online only. Nothing is logged, so check `CometChat.isOfflineEnabled()` + after login to confirm offline support is running. + + +### Expo + +The modules are native code, so they don't run in Expo Go — in Expo Go, offline support stays off. Use a [development build](https://docs.expo.dev/develop/development-builds/introduction/) or an EAS build: install the packages with `npx expo install`, then rebuild. Expo prebuild links the modules automatically, so no config plugin is needed. + +## Enable Offline Support + +Create a `CometChat.OfflineConfig` with `enabled: true` and pass it to `setOfflineConfig()` on the `AppSettingsBuilder`, then initialize the SDK as usual. Always set `enabled: true` explicitly — the other options have no effect until offline support is enabled. + + + + ```typescript + let appID: string = "APP_ID"; + let region: string = "APP_REGION"; + + let offlineConfig: CometChat.OfflineConfig = new CometChat.OfflineConfig({ + enabled: true, + maxPendingMessages: 50, + mediaCaching: CometChat.MediaCachingMode.AUTO, + }); + + let appSetting: CometChat.AppSettings = new CometChat.AppSettingsBuilder() + .subscribePresenceForAllUsers() + .setRegion(region) + .setOfflineConfig(offlineConfig) + .build(); + + CometChat.init(appID, appSetting).then( + () => { + console.log("Initialization completed successfully"); + }, + (error: CometChat.CometChatException) => { + console.log("Initialization failed with error:", error); + } + ); + ``` + + + ```javascript + let appID = "APP_ID"; + let region = "APP_REGION"; + + let offlineConfig = new CometChat.OfflineConfig({ + enabled: true, + maxPendingMessages: 50, + mediaCaching: CometChat.MediaCachingMode.AUTO, + }); + + let appSetting = new CometChat.AppSettingsBuilder() + .subscribePresenceForAllUsers() + .setRegion(region) + .setOfflineConfig(offlineConfig) + .build(); + + CometChat.init(appID, appSetting).then( + () => { + console.log("Initialization completed successfully"); + }, + (error) => { + console.log("Initialization failed with error:", error); + } + ); + ``` + + + +The offline store opens when a user logs in (or an existing session is restored) and closes on logout. + +### Configuration Options + +Set `enabled: true`. Every other option is optional and uses its default when you leave it out. The options are read once at `init()` and stay fixed for the session. + +| Option | Type | Default | Description | +| --- | --- | --- | --- | +| `enabled` | `boolean` | `false` | Turns offline support on. Nothing is stored on the device unless this is `true`. Always set it explicitly. | +| `maxConversations` | `number` | `100` | How many conversations the cache keeps. The least recently used are removed first. Range 1–1000. | +| `maxMessagesPerConversation` | `number` | `500` | How many of the newest messages the cache keeps per conversation. Range 1–1000. | +| `maxStorageSize` | `number` (bytes) | `262144000` (250 MB) | Space the cached data may use, not counting media. The oldest data is removed first. `0` means no limit. | +| `cacheDurationDays` | `number` (days) | `0` | Removes a cached conversation that has had no new message and hasn't been opened for this many days. `0` means no time limit. | +| `maxSnapshotQueries` | `number` | `100` | How many distinct cached lists — users, groups, members, banned members, reactions, notification feed — are kept. The least recently used are removed first. `0` or less uses the default. | +| `snapshotTtlDays` | `number` (days) | `7` | How long a cached list stays available before it is removed. `0` or less uses the default. | +| `maxPendingMessages` | `number` | `50` | How many messages waiting to be sent (`PENDING` or `SENDING`) the outbox can hold. `FAILED` messages don't count. Range 1–100. | +| `pendingQueueOverflowPolicy` | `CometChat.PendingMessageOverflowPolicy` | `REJECT_NEWEST` | What happens when the outbox is full: `REJECT_NEWEST` rejects the new send, `DROP_OLDEST` removes the oldest `PENDING` message to make room. | +| `autoSendPendingOnReconnect` | `boolean` | `true` | Sends queued messages automatically after a reconnect. It does not affect the catch-up sync. | +| `mediaCaching` | `CometChat.MediaCachingMode` | `AUTO` | `AUTO` downloads attachments of fetched and received messages so they can be viewed offline. `OFF` caches the message and its attachment details but not the file itself. Sending media works in both modes. | +| `maxMediaCacheSize` | `number` (bytes) | `524288000` (500 MB) | Space cached media may use. The least recently used files are removed first. `0` means no limit. | +| `encryption` | `boolean` | `false` | Encrypts the local database. See [Encrypt the Cache](#encrypt-the-cache). | +| `encryptionFailureBehavior` | `CometChat.EncryptionFailureBehavior` | `ONLINE_ONLY` | Used only when `encryption` is `true` and encryption can't run. `ONLINE_ONLY` keeps no cache and works online only; `PLAINTEXT_FALLBACK` opens an unencrypted cache instead. | + +For the options with a range, a value above the maximum — or `0` — is treated as the maximum. An invalid value, such as an unknown enum value, falls back to the default instead of throwing. Unsent messages are never removed to free space. + +`OfflineConfig` is immutable — read values back with its getters, such as `isEnabled()` and `getMaxPendingMessages()`. + +If you initialize from a settings file, put the same options under `chatSDK.offline`. There too, offline support stays off unless the object sets `enabled: true`. + +## Check Whether Offline Is Running + +`CometChat.isOfflineEnabled()` returns `true` only while offline support is actually running for the logged-in user: you enabled it, a user is logged in, and the local store is open. It returns `false` before login, after logout, and when CometChat has switched offline support off for your app. Read it each time you need it instead of storing the value. + + + + ```typescript + let offlineRunning: boolean = CometChat.isOfflineEnabled(); + ``` + + + ```javascript + let offlineRunning = CometChat.isOfflineEnabled(); + ``` + + + +CometChat can turn offline support off for an app from the server. When that happens, the SDK stops using the local store, deletes the cached data and `isOfflineEnabled()` returns `false`. Your app keeps working online. + +## Read From the Cache, Then the Server + +Pass a fetch listener to a request's `fetchPrevious()` or `fetchNext()` to receive results in two steps: + +1. `onCached*` — what is stored on the device, delivered immediately. It can be an empty list. +2. `onServer*` — the server's result, which also updates the cache. If the device is offline, or the request fails, `onError` is called instead. + +The call still returns a Promise that settles with the server result, so handle the outcome in one place. Without a listener, the request reads from the server only, exactly as before. + +### Messages + + + + ```typescript + let UID: string = "UID"; + let limit: number = 30; + + let messagesRequest: CometChat.MessagesRequest = + new CometChat.MessagesRequestBuilder() + .setUID(UID) + .setLimit(limit) + .build(); + + messagesRequest + .fetchPrevious({ + onCachedMessages: (messages: CometChat.BaseMessage[]) => { + // Render the cached page right away + }, + onServerMessages: (messages: CometChat.BaseMessage[]) => { + // Replace it with the server's page + }, + onError: (error: CometChat.CometChatException) => { + // Offline or failed — keep showing the cached page + }, + }) + .catch(() => { + // Already handled in onError + }); + ``` + + + ```javascript + let UID = "UID"; + let limit = 30; + + let messagesRequest = new CometChat.MessagesRequestBuilder() + .setUID(UID) + .setLimit(limit) + .build(); + + messagesRequest + .fetchPrevious({ + onCachedMessages: (messages) => { + // Render the cached page right away + }, + onServerMessages: (messages) => { + // Replace it with the server's page + }, + onError: (error) => { + // Offline or failed — keep showing the cached page + }, + }) + .catch(() => { + // Already handled in onError + }); + ``` + + + +### Conversations + + + + ```typescript + let conversationsRequest: CometChat.ConversationsRequest = + new CometChat.ConversationsRequestBuilder().setLimit(30).build(); + + conversationsRequest + .fetchNext({ + onCachedConversations: (conversations: CometChat.Conversation[]) => { + // Render the cached list + }, + onServerConversations: (conversations: CometChat.Conversation[]) => { + // Replace it with the server's list + }, + onError: (error: CometChat.CometChatException) => { + // Offline or failed + }, + }) + .catch(() => {}); + ``` + + + ```javascript + let conversationsRequest = new CometChat.ConversationsRequestBuilder() + .setLimit(30) + .build(); + + conversationsRequest + .fetchNext({ + onCachedConversations: (conversations) => { + // Render the cached list + }, + onServerConversations: (conversations) => { + // Replace it with the server's list + }, + onError: (error) => { + // Offline or failed + }, + }) + .catch(() => {}); + ``` + + + +### A Single Conversation + +`getConversation()` accepts a third argument. When the conversation is not cached, `onCachedConversation` is called with `undefined`. + + + + ```typescript + let conversationWith: string = "UID"; + let conversationType: string = "user"; + + CometChat.getConversation(conversationWith, conversationType, { + onCachedConversation: (conversation: CometChat.Conversation | undefined) => {}, + onServerConversation: (conversation: CometChat.Conversation) => {}, + onError: (error: CometChat.CometChatException) => {}, + }).catch(() => {}); + ``` + + + ```javascript + CometChat.getConversation("UID", "user", { + onCachedConversation: (conversation) => {}, + onServerConversation: (conversation) => {}, + onError: (error) => {}, + }).catch(() => {}); + ``` + + + +### Other Lists + +The same pattern works for these requests: + +| Request | Callbacks | +| --- | --- | +| `UsersRequest`, `BlockedUsersRequest` | `onCachedUsers` · `onServerUsers` · `onError` | +| `GroupsRequest` | `onCachedGroups` · `onServerGroups` · `onError` | +| `GroupMembersRequest` | `onCachedGroupMembers` · `onServerGroupMembers` · `onError` | +| `BannedMembersRequest` | `onCachedBannedGroupMembers` · `onServerBannedGroupMembers` · `onError` | +| `ReactionsRequest` | `onCachedReactions` · `onServerReactions` · `onError` | +| `NotificationFeedRequest` | `onCachedFeedItems` · `onServerFeedItems` · `onError` | + +These lists are served from the cache only for a request that was loaded online before with the same filters. A list that has never been loaded online has nothing to show offline. + +### When the Cache Is Skipped + +The cache only answers a request when it can apply every filter on the request correctly. If a request uses a filter that only the server can evaluate — for example `setSearchKeyword()` — `onCached*` is called with an empty list and the server's result follows. Don't show an empty state for that empty cache page; wait for the server phase. + +While offline support is not running, `onCached*` is not called at all and only the server answers. + +## Send Messages While Offline + +Pass a `MessageSentListener` as the second argument to `sendMessage()`, `sendMediaMessage()` or `sendCustomMessage()`. The listener has three callbacks: + +| Callback | When it fires | +| --- | --- | +| `onMessagePending(message)` | The message is saved in the outbox. `message.getMessageStatus()` is `CometChat.MessageStatus.PENDING` and `message.getLocalMessageId()` identifies it. Show it in the UI as pending. | +| `onMessageSent(message)` | The server accepted the message. It carries the same `getLocalMessageId()`, so you can replace the pending copy with this one. | +| `onError(error, reason)` | Either the message is queued (`reason` is `null` and `error.errorParams.queued` is `true`) or the send failed (`reason` is a `CometChat.MessageFailureReason`). See [Queued or Failed](#queued-or-failed). | + + + + ```typescript + let receiverID: string = "UID"; + let receiverType: string = CometChat.RECEIVER_TYPE.USER; + let textMessage: CometChat.TextMessage = new CometChat.TextMessage( + receiverID, + "Hello", + receiverType + ); + + CometChat.sendMessage(textMessage, { + onMessagePending: (message: CometChat.BaseMessage) => { + // Show the message with a pending indicator, + // keyed by message.getLocalMessageId() + }, + onMessageSent: (message: CometChat.BaseMessage) => { + // Swap the pending copy (same getLocalMessageId()) for the sent message + }, + onError: ( + error: CometChat.CometChatException, + reason?: CometChat.MessageFailureReason | null + ) => { + if (reason == null && error.errorParams?.queued === true) { + // Queued, not failed — keep the pending indicator. + // onMessageSent fires after the connection returns. + return; + } + // The send failed — reason says why + }, + }).catch(() => { + // Rejects for a queued send as well as a failed one — + // handle the outcome in the listener + }); + ``` + + + ```javascript + let receiverID = "UID"; + let receiverType = CometChat.RECEIVER_TYPE.USER; + let textMessage = new CometChat.TextMessage(receiverID, "Hello", receiverType); + + CometChat.sendMessage(textMessage, { + onMessagePending: (message) => { + // Show the message with a pending indicator, + // keyed by message.getLocalMessageId() + }, + onMessageSent: (message) => { + // Swap the pending copy (same getLocalMessageId()) for the sent message + }, + onError: (error, reason) => { + if (reason == null && error.errorParams?.queued === true) { + // Queued, not failed — keep the pending indicator. + // onMessageSent fires after the connection returns. + return; + } + // The send failed — reason says why + }, + }).catch(() => { + // Rejects for a queued send as well as a failed one — + // handle the outcome in the listener + }); + ``` + + + +What happens depends on the connection: + +- **Online:** `onMessagePending`, then `onMessageSent`. The Promise resolves with the sent message. +- **Offline:** `onMessagePending`, then one `onError` with `reason` `null` — the queued notice — and the Promise rejects with the same error. The message stays `PENDING` in the outbox and is sent after the connection returns, when `onMessageSent` fires. The same notice fires once if the device is online but the first attempt gets no answer. Do not resend the message yourself, or the recipient receives it twice. +- **Outbox full:** with the default `REJECT_NEWEST` policy, the new send fails with `onError`, the code `ERROR_PENDING_QUEUE_LIMIT` and the reason `QUEUE_FULL`. With `DROP_OLDEST`, the oldest `PENDING` message is removed instead and its own `onError` fires with the same code and the reason `QUEUE_OVERFLOW`. A media message too large for `maxMediaCacheSize` fails with the reason `BYTE_OVERFLOW`. + + + Only sends that pass a `MessageSentListener` are queued. A send without a + listener behaves exactly as before: while offline, its Promise rejects and + the message is not saved. + + +Treat the message as pending from the moment `onMessagePending` fires, and use `getLocalMessageId()` to match it to the message you later get in `onMessageSent` or `onError`. The local ID is assigned by the SDK and is separate from any `muid` you set. + +The listener lives in memory only. If the app restarts while a message is queued, the message is still sent on reconnect, but the old listener is gone — call [`getPendingMessages()`](#get-pending-messages) on start-up to show queued messages again, and use [`addOfflineMessageListener()`](#other-offline-methods) to follow them. + +### Queued or Failed + +`onError` — and the Promise's rejection — covers two different outcomes. Tell them apart before you show an error: + +| | Queued | Failed | +| --- | --- | --- | +| `reason` in `onError` | `null` | A `CometChat.MessageFailureReason` | +| `error.errorParams` | `{ queued: true, status: "PENDING", localMessageId }` | No `queued` flag | +| `error.code` | `FAILED_TO_FETCH`, the usual not-connected code | Depends on the cause, for example `ERROR_PENDING_QUEUE_LIMIT` | +| Message status | `PENDING` | `FAILED`, or removed for `QUEUE_FULL`, `QUEUE_OVERFLOW`, `BYTE_OVERFLOW` and `DISCARDED` | +| What follows | `onMessageSent` after reconnect | Nothing, unless you retry it | + +On the Promise rejection, which has no `reason`, check `error.errorParams?.queued === true`. + +### Get Pending Messages + +`CometChat.getPendingMessages()` resolves with the messages that are still in the outbox — pending, sending and failed — oldest first, each with `getLocalMessageId()` set. Use it to show unsent messages again after the app restarts. `message.getMessageStatus()` tells them apart: `CometChat.MessageStatus.PENDING` or `SENDING` for a message waiting to be sent, `FAILED` for one that needs a retry. + + + + ```typescript + CometChat.getPendingMessages().then((messages: CometChat.BaseMessage[]) => { + messages.forEach((message: CometChat.BaseMessage) => { + if (message.getMessageStatus() === CometChat.MessageStatus.FAILED) { + // Show the message with a failed indicator and a retry action + } else { + // Show the message with a pending indicator + } + }); + }); + ``` + + + ```javascript + CometChat.getPendingMessages().then((messages) => { + messages.forEach((message) => { + if (message.getMessageStatus() === CometChat.MessageStatus.FAILED) { + // Show the message with a failed indicator and a retry action + } else { + // Show the message with a pending indicator + } + }); + }); + ``` + + + +It resolves with an empty list while offline support is not running. + +## Failed Messages and Retry + +A queued message is `PENDING` until it is sent. While the device is offline it stays `PENDING` for as long as it takes — it does not fail. The SDK sends it when the connection returns; a send that gets no answer in time (15 seconds, or 120 seconds for media) counts as no answer and the message stays `PENDING` for the next reconnect. + +A message ends with `onError` and one of these reasons: + +| Reason | When | Message afterwards | Retry | +| --- | --- | --- | --- | +| `TRANSIENT` | The server was overloaded or rate-limited the request (HTTP 5xx or 429), or the message got no answer on 5 reconnects in a row (code `ERROR_OUTBOX_AUTO_FLUSH_CAP_REACHED`). | `FAILED`, in the outbox | `retryMessage()` or `retryAllFailedMessages()` | +| `PERMANENT` | The server rejected it — for example, the receiver has blocked the user or the user lacks permission. | `FAILED`, in the outbox | `retryMessage()`, once the cause is fixed | +| `QUEUE_FULL`, `QUEUE_OVERFLOW`, `BYTE_OVERFLOW` | The outbox refused or evicted it (code `ERROR_PENDING_QUEUE_LIMIT`). | Removed | Send it again as a new message | +| `DISCARDED` | It was removed before it was sent — by `clearOfflineCache()`, or because its conversation was removed from the cache (for example, the conversation was deleted or the user left the group). Code `ERROR_OFFLINE_MESSAGE_DISCARDED`. | Removed | None — final | + +The per-send `onError` fires if the listener is still in memory, and `onOutboxMessageFailed` fires on every [offline message listener](#other-offline-methods). A failed message steps aside — the next message in the conversation is still sent. + +**The SDK never retries a `FAILED` message on its own.** It stays in the outbox until you retry it and it is sent, or the cache is cleared. It doesn't count toward `maxPendingMessages`. Let the user decide, then call `CometChat.retryMessage()` with the message's local ID: + + + + ```typescript + let localId: string | undefined = failedMessage.getLocalMessageId(); + + if (localId) { + // Show the message as pending again, then retry it + CometChat.retryMessage(localId, { + onMessageSent: (message: CometChat.BaseMessage) => { + // Sent — replace the failed copy + }, + onError: ( + error: CometChat.CometChatException, + reason?: CometChat.MessageFailureReason | null + ) => { + if (reason == null && error.errorParams?.queued === true) { + // Offline — queued again, sent on reconnect + return; + } + // Failed again, or not retryable + // (code ERROR_OFFLINE_MESSAGE_NOT_RETRYABLE) + }, + }); + } + ``` + + + ```javascript + let localId = failedMessage.getLocalMessageId(); + + if (localId) { + // Show the message as pending again, then retry it + CometChat.retryMessage(localId, { + onMessageSent: (message) => { + // Sent — replace the failed copy + }, + onError: (error, reason) => { + if (reason == null && error.errorParams?.queued === true) { + // Offline — queued again, sent on reconnect + return; + } + // Failed again, or not retryable + // (code ERROR_OFFLINE_MESSAGE_NOT_RETRYABLE) + }, + }); + } + ``` + + + +`retryMessage()` only accepts a `FAILED` message. For any other local ID — a message that is still pending, already sent, or unknown — it ends with `onError` and the code `ERROR_OFFLINE_MESSAGE_NOT_RETRYABLE` (`CometChat.OfflineErrorCode.MESSAGE_NOT_RETRYABLE`) and sends nothing, so it can't create a duplicate. + +Without a listener, `retryMessage(localId)` returns a Promise that resolves with the sent message. If the device is offline, the message goes back to `PENDING`, the listener or Promise gets the same queued notice as a new send, and the message is sent on reconnect. + +`CometChat.retryAllFailedMessages()` retries every failed message whose reason is `TRANSIENT`. Messages the server rejected (`PERMANENT`) are skipped, because they would fail again — retry those one at a time with `retryMessage()` once the cause is fixed. Pass a `MessageSentListener` to follow each retried message. + +## Listen for Sync Events + +After a reconnect, the SDK runs a catch-up sync in two phases: conversations, then messages. Register a sync listener to know when each phase starts, finishes or fails — for example, to refresh a screen once the sync is complete. + + + + ```typescript + let listenerID: string = "UNIQUE_LISTENER_ID"; + + CometChat.addSyncListener(listenerID, { + onConversationSyncStarted: () => {}, + onConversationSyncCompleted: () => { + // Re-fetch the conversation list + }, + onConversationSyncError: (error: CometChat.CometChatException) => {}, + onMessageSyncStarted: () => {}, + onMessageSyncCompleted: ( + messages: CometChat.BaseMessage[], + outcome: CometChat.SyncOutcome + ) => { + // Re-fetch the open message list + }, + onMessageSyncError: (error: CometChat.CometChatException) => {}, + }); + + // Remove it when you no longer need it + CometChat.removeSyncListener(listenerID); + ``` + + + ```javascript + let listenerID = "UNIQUE_LISTENER_ID"; + + CometChat.addSyncListener(listenerID, { + onConversationSyncStarted: () => {}, + onConversationSyncCompleted: () => { + // Re-fetch the conversation list + }, + onConversationSyncError: (error) => {}, + onMessageSyncStarted: () => {}, + onMessageSyncCompleted: (messages, outcome) => { + // Re-fetch the open message list + }, + onMessageSyncError: (error) => {}, + }); + + // Remove it when you no longer need it + CometChat.removeSyncListener(listenerID); + ``` + + + +`onConversationSyncCompleted` receives the refreshed conversations. `onMessageSyncCompleted` receives the new and updated messages and a `CometChat.SyncOutcome`: `CAUGHT_UP` when the sync fetched every change and the cache is up to date, or `PARTIAL` when it stopped early and some changes remain for a later sync. Each callback is optional. + +The sync waits for the outbox to send queued messages, for up to 20 seconds, before it starts. + + + Catching up on edits, deletes and reactions made while the device was offline + relies on a CometChat server capability. If it isn't available for your app + yet, every sync reports `CAUGHT_UP` and those changes are not picked up until + the messages are fetched again. Sending, reading and the cache work either + way. Contact CometChat support if you're not sure. + + +## Other Offline Methods + +All of these are static methods on `CometChat`. They are safe to call while offline support is not running, and return the empty value shown. + +| Method | Returns | Description | +| --- | --- | --- | +| `forceSyncNow(callback?)` | `Promise` | Runs the catch-up sync now instead of waiting for a reconnect. Pass `{ onSuccess, onError }` to learn why a sync didn't run: `ERROR_OFFLINE_DISABLED`, `ERROR_USER_NOT_LOGGED_IN`, `ERROR_OFFLINE_SYNC_IN_PROGRESS`, or `ERROR_OFFLINE_SYNC_COOLDOWN` within 10 seconds of the start of the last sync, including one started by a reconnect (`error.details.retryAfterMs` says when to try again). Without a callback, these cases resolve without syncing. | +| `getSyncState()` | `CometChat.SyncState` | `SYNCING` while a catch-up sync runs, otherwise `IDLE`. | +| `getLastSyncTimestamp()` | `Promise` | Server time of the last completed sync, in **seconds** since the epoch, or `0`. Multiply by 1000 before passing it to `new Date()`. | +| `getCacheSize()` | `Promise` | Size of the offline cache in bytes — the local database plus cached media — or `0`. | +| `clearOfflineCache()` | `Promise` | Deletes all cached data and media while the user stays logged in. Every `PENDING` or `SENDING` message ends with `onError` and the reason `DISCARDED`, and is not sent; `FAILED` messages are removed without a callback. Offline support keeps running with an empty cache. | +| `downloadMedia(message, listener?)` | `Promise` | Downloads a media message's attachment to the cache, or returns it if it is already cached. Resolves with the local file path, or `null`. | +| `getCachedMediaPath(message)` | `Promise` | The local file path of a cached attachment, or `null` if it isn't cached. Never downloads. | +| `getMediaCachingMode()` | `CometChat.MediaCachingMode` | The media caching mode in effect: your `mediaCaching` value while offline support is running, and `OFF` whenever it isn't. Fixed for the session. `getMediaCaching()` is a deprecated alias that returns the same value. | +| `addMediaDownloadListener(id, listener)` / `removeMediaDownloadListener(id)` | `Promise` / `void` | An app-wide observer for background media downloads: `onMediaDownloaded(localMessageId, attachmentId, localPath)`, `onMediaProgress(localMessageId, attachmentId, percent)`, `onMediaDownloadFailed(localMessageId, attachmentId, error)`. | +| `addOfflineMessageListener(id, listener)` / `removeOfflineMessageListener(id)` | `void` | An app-wide observer for every outbox message, including ones queued before a restart: `onOutboxMessagePending(message)`, `onOutboxMessageSent(message)`, `onOutboxMessageFailed(message, reason)`. | +| `addOfflineCacheResetListener(id, listener)` / `removeOfflineCacheResetListener(id)` | `void` | Tells you when the SDK had to delete and rebuild the cache on its own. See [Cache Resets](#cache-resets). | + +## Encrypt the Cache + +Set `encryption: true` to encrypt the local database with SQLCipher. Encryption is off by default and needs two extra pieces in your app: + +1. Turn on SQLCipher for op-sqlite in your app's `package.json`, then run `pod install` and rebuild the app for both iOS and Android: + + ```json + { + "op-sqlite": { + "sqlcipher": true + } + } + ``` + + op-sqlite reads this flag when the native app is built. In Expo, add it before you run `npx expo prebuild` or an EAS build; Expo Go can't use it. + +2. Install [`react-native-keychain`](https://github.com/oblador/react-native-keychain), where the SDK keeps the encryption key: + + ```bash + npm install react-native-keychain + ``` + + To keep the key somewhere else, pass your own key vault to `CometChat.setOfflineKeyVault()` before `CometChat.init()` instead — see [Use Your Own Key Vault](#use-your-own-key-vault). + + + + ```typescript + let offlineConfig: CometChat.OfflineConfig = new CometChat.OfflineConfig({ + enabled: true, + encryption: true, + }); + ``` + + + ```javascript + let offlineConfig = new CometChat.OfflineConfig({ + enabled: true, + encryption: true, + }); + ``` + + + +`CometChat.getOfflineEncryptionStatus()` reports what is actually in effect — `enabled` is what you asked for, and `atRest`, `keyStorage` and `cipherAvailable` describe what the SDK achieved. Check it after login. With encryption working it looks like this: + +```json +{ + "enabled": true, + "atRest": "SQLCIPHER", + "keyStorage": "KEYSTORE", + "cipherAvailable": true, + "cipher": "4.6.1 community", + "leakage": "Downloaded and queued media files are stored unencrypted." +} +``` + +`cipher` is the SQLCipher version in your build, and `keyStorage` is `"KEYSTORE"` for `react-native-keychain` or `"SOFTWARE"` for your own key vault. + +If encryption can't run, `atRest` is `"NONE"` and `keyStorage` is `"NONE"`. `cipherAvailable: false` means SQLCipher isn't in the build; `cipherAvailable: true` means there is no key store — install `react-native-keychain` or set a key vault. By default (`encryptionFailureBehavior: ONLINE_ONLY`) the SDK then does not write an unencrypted cache — offline support stays off and the app works online only. Set `encryptionFailureBehavior` to `CometChat.EncryptionFailureBehavior.PLAINTEXT_FALLBACK` only if keeping offline support matters more than encrypting the cache; the SDK then opens an unencrypted cache. + +Cached media files are not encrypted. Set `mediaCaching: CometChat.MediaCachingMode.OFF` if attachments must not be stored on the device. This is not end-to-end encryption; it only affects the copy stored on the device. + +Encryption is read once at `init()`. If you change `encryption` for an app that already has a cache, the SDK deletes the old cache and builds a new one, and tells you through a [cache reset](#cache-resets). + +### Use Your Own Key Vault + +`react-native-keychain` is detected automatically. To store the key elsewhere, pass an object with `get`, `set`, `delete` and `kind` to `CometChat.setOfflineKeyVault()` before `CometChat.init()`. The SDK stores a 64-character hex key under a service name, and never exposes the key through a public API. Pass `null` to go back to automatic detection. + + + + ```typescript + CometChat.setOfflineKeyVault({ + get: async (service: string): Promise => { + // Return the stored key for this service, or null + return null; + }, + set: async (service: string, hexKey: string): Promise => { + // Store the key for this service, replacing any existing one + }, + delete: async (service: string): Promise => { + // Remove the key; resolve even if nothing is stored + }, + kind: () => "SOFTWARE", + }); + ``` + + + ```javascript + CometChat.setOfflineKeyVault({ + get: async (service) => { + // Return the stored key for this service, or null + return null; + }, + set: async (service, hexKey) => { + // Store the key for this service, replacing any existing one + }, + delete: async (service) => { + // Remove the key; resolve even if nothing is stored + }, + kind: () => "SOFTWARE", + }); + ``` + + + +## Cache Resets + +If the SDK can't use the existing cache — the encryption key was lost or doesn't match, the database is corrupted or keeps failing to open, or you changed the `encryption` setting — it deletes the cache and starts a new one. Queued messages in the old cache are lost. Register an offline cache reset listener to find out: + + + + ```typescript + let listenerID: string = "UNIQUE_LISTENER_ID"; + + CometChat.addOfflineCacheResetListener(listenerID, { + onOfflineCacheReset: ( + reason: CometChat.OfflineCacheResetReason, + pendingMessagesLost: number + ) => { + if (pendingMessagesLost === CometChat.OFFLINE_PENDING_COUNT_UNKNOWN) { + // Unsent messages may have been lost, but they couldn't be counted + } + }, + }); + + // Remove it when you no longer need it + CometChat.removeOfflineCacheResetListener(listenerID); + ``` + + + ```javascript + let listenerID = "UNIQUE_LISTENER_ID"; + + CometChat.addOfflineCacheResetListener(listenerID, { + onOfflineCacheReset: (reason, pendingMessagesLost) => { + if (pendingMessagesLost === CometChat.OFFLINE_PENDING_COUNT_UNKNOWN) { + // Unsent messages may have been lost, but they couldn't be counted + } + }, + }); + + // Remove it when you no longer need it + CometChat.removeOfflineCacheResetListener(listenerID); + ``` + + + +`reason` is one of `CometChat.OfflineCacheResetReason`: `KEY_LOST`, `WRONG_KEY`, `CORRUPTED`, `ESCALATED` (the cache failed to open repeatedly) or `ENCRYPTION_CHANGED`. `pendingMessagesLost` is the number of unsent messages that were lost, or `-1` (`CometChat.OFFLINE_PENDING_COUNT_UNKNOWN`) when they couldn't be counted — it doesn't mean none were lost. Resets happen when the store opens — at login, or when a saved session is restored — and are not replayed, so register the listener before you call `CometChat.init()`. + +## Logging Out and Switching Users + +| Event | What happens to the local store | +| --- | --- | +| Logout, or the session ends | Deleted — cached data, cached media and queued messages. Queued messages are discarded without a callback. | +| Login as the same user again (for example, with a new auth token) | Kept, including queued messages, which are sent once the user is connected. | +| Login as a different user | The previous user's data is deleted before the new user's store opens. | +| A login that fails | The previous user's data is deleted, as on logout — the user ends up logged out. | + +Do not clear the cache yourself on logout; use `clearOfflineCache()` only to empty the cache while the user stays logged in. + +## Limitations + +- The cache holds what your app has loaded while online, plus the changes picked up by the catch-up sync. Older history that was never loaded is not available offline. +- Only messages sent with a `MessageSentListener` are queued offline. Other actions — such as editing, deleting, reacting, pinning or marking as read — need a connection. +- Queued and failed messages are discarded on logout and by `clearOfflineCache()`. `clearOfflineCache()` reports each queued message as `DISCARDED`; logout does not. +- Failed messages are not retried automatically — your app decides when to call `retryMessage()` or `retryAllFailedMessages()`. + +--- + +## Next Steps + + + + Send text, media and custom messages + + + Listen for messages and fetch message history + + + Fetch and paginate the conversation list + + + Track the WebSocket connection + + diff --git a/ui-kit/react-native/core-features.mdx b/ui-kit/react-native/core-features.mdx index 9fb0f61c2..77df14b20 100644 --- a/ui-kit/react-native/core-features.mdx +++ b/ui-kit/react-native/core-features.mdx @@ -13,7 +13,7 @@ description: "Explore CometChat React Native UI Kit core messaging features, inc | Where they render | `CometChatMessageList` and `CometChatMessageComposer` render these themselves | | Turning one OFF | every one has a `hide*` prop on its component (e.g. `hideReactionOption`, `hideReplyInThreadOption`) — see [Message List](/ui-kit/react-native/message-list) | | Common mistake | Enabling the legacy **Reactions** or **Mentions** dashboard extension. Both are already core in v5 and those extensions are scheduled for deprecation — enabling them is unnecessary | -| Related | [Message List](/ui-kit/react-native/message-list) · [Message Composer](/ui-kit/react-native/message-composer) · [Extensions](/ui-kit/react-native/extensions) | +| Related | [Message List](/ui-kit/react-native/message-list) · [Message Composer](/ui-kit/react-native/message-composer) · [Extensions](/ui-kit/react-native/extensions) · [Offline Support](/ui-kit/react-native/offline-support) (opt-in, off by default) | @@ -285,6 +285,16 @@ CometChat facilitates Group Chats, allowing users to have conversations with mul For a comprehensive understanding and guide on implementing and using the Groups feature in CometChat, refer to the detailed guide on [Groups](/ui-kit/react-native/groups). +## Offline Support + +With offline support turned on, conversations and messages load from a cache on the device first and refresh from the server when it is reachable. Messages sent without a connection appear right away and are sent automatically when the device reconnects. Offline support is off by default — turn it on with the `offline` field of `UIKitSettings`, and install the native modules it needs. See [Offline Support](/ui-kit/react-native/offline-support) for setup and limitations. + +| Components | Functionality | +| --- | --- | +| [Conversations](/ui-kit/react-native/conversations) | Renders the cached conversation list immediately, then updates it with the server's list. Marks a conversation that has a queued (clock icon) or failed (error icon) message. | +| [MessageList](/ui-kit/react-native/message-list) | Opens a conversation with its cached messages and media, then updates it with the server's page. | +| [MessageComposer](/ui-kit/react-native/message-composer) | Queues messages sent while offline; they show a clock icon and are delivered automatically when the connection returns. A message that fails is not retried automatically. | + --- ## Next Steps diff --git a/ui-kit/react-native/llms-react-native-v5.mdx b/ui-kit/react-native/llms-react-native-v5.mdx index 0e0233d05..c471e81aa 100644 --- a/ui-kit/react-native/llms-react-native-v5.mdx +++ b/ui-kit/react-native/llms-react-native-v5.mdx @@ -74,6 +74,7 @@ components, theming tokens, or feature enablement. ## Core & configuration - [Core Features](/ui-kit/react-native/core-features) +- [Offline Support](/ui-kit/react-native/offline-support) - [Methods](/ui-kit/react-native/methods) - [Events](/ui-kit/react-native/events) - [Sound Manager](/ui-kit/react-native/sound-manager) diff --git a/ui-kit/react-native/offline-support.mdx b/ui-kit/react-native/offline-support.mdx new file mode 100644 index 000000000..90fcdb916 --- /dev/null +++ b/ui-kit/react-native/offline-support.mdx @@ -0,0 +1,186 @@ +--- +title: "Offline Support" +description: "Let the CometChat React Native UI Kit show cached conversations and messages without a network and send queued messages when the connection returns." +--- + + + +| Field | Value | +| --- | --- | +| Package | `@cometchat/chat-uikit-react-native` v5.x | +| Extra deps | `@react-native-community/netinfo` (required by the UI Kit) · `@op-engineering/op-sqlite` 9.0.0+ (required for offline) · `react-native-fs` (media caching) · `react-native-keychain` (encryption only) — native modules, so Expo needs a development build or EAS build; Expo Go is not supported | +| Enable | Add `offline: { enabled: true, ... }` — or `offline: true`, or a `CometChat.OfflineConfig` — to the `UIKitSettings` passed to `CometChatUIKit.init()`. Offline support is **off by default**; always set `enabled: true` in an options object | +| Config | The value is converted with the React Native SDK's `OfflineConfig` — options are listed in [React Native SDK Offline Support](/sdk/react-native/offline-support#configuration-options) | +| Runtime check | `CometChat.isOfflineEnabled()` from `@cometchat/chat-sdk-react-native` | +| Components that use the cache | `CometChatConversations` → [Conversations](/ui-kit/react-native/conversations), `CometChatMessageList` → [Message List](/ui-kit/react-native/message-list), `CometChatMessageComposer` → [Message Composer](/ui-kit/react-native/message-composer) | +| Component props | None — offline support is turned on in UI Kit settings only | +| Message states | A queued message shows a clock icon; a failed one shows an error icon — in the message list and on the conversation's row. A message discarded before it was sent is final | +| Not included | No offline banner, no automatic retry of failed messages and no "retry" action for a failed message | + + + +With offline support turned on, the UI Kit keeps showing the user's chats when the network drops. Conversations and messages load from a cache on the device first and are refreshed from the server when it is reachable, and messages sent while offline are delivered automatically once the connection returns. + +The UI Kit builds on the [offline support in the React Native SDK](/sdk/react-native/offline-support). You turn it on in UI Kit settings; the components handle the rest. + +{/* TODO: replace v5.x.x with the first UI Kit release that ships offline support (UI Kit PR #1434) before this page goes live. */} + + Offline support is available from React Native UI Kit **v5.x.x** with React Native SDK **v4.2.0** or later. + + +## Install the Dependencies + +| Package | Version | Needed for | +| --- | --- | --- | +| `@react-native-community/netinfo` | Any | **Required by the UI Kit**, even with offline support off — the Message List uses it to detect when the network returns, and the app bundle fails to build without it. | +| `@op-engineering/op-sqlite` | 9.0.0 or later | **Required for offline support.** The SDK's local database. | +| `react-native-fs` | Any | Caching media files on the device. | +| `react-native-keychain` | Any | [Encrypting the cache](/sdk/react-native/offline-support#encrypt-the-cache) only. | + +Offline support needs **React Native 0.74 or later**, because current versions of `@op-engineering/op-sqlite` require it. + +```bash +npm install @react-native-community/netinfo @op-engineering/op-sqlite react-native-fs +cd ios && pod install +``` + +These are native modules, so rebuild the app after installing them. In an Expo app, install them with `npx expo install` and rebuild your [development build](https://docs.expo.dev/develop/development-builds/introduction/) or EAS build — they don't run in Expo Go, where offline support stays off. + + + Without `@op-engineering/op-sqlite`, offline support stays off and the UI Kit + works online only. Nothing is logged, so check `CometChat.isOfflineEnabled()` + after login to confirm offline support is running. + + +## Enable Offline Support + +Add the `offline` field to your `UIKitSettings`: + + + +```tsx +import { CometChatUIKit, UIKitSettings } from "@cometchat/chat-uikit-react-native"; +import { CometChat } from "@cometchat/chat-sdk-react-native"; + +const uiKitSettings: UIKitSettings = { + appId: "APP_ID", + authKey: "AUTH_KEY", + region: "REGION", + subscriptionType: CometChat.AppSettings.SUBSCRIPTION_TYPE_ALL_USERS as UIKitSettings["subscriptionType"], + offline: { + enabled: true, + mediaCaching: CometChat.MediaCachingMode.AUTO, + }, +}; + +CometChatUIKit.init(uiKitSettings) + .then(() => { + console.log("CometChat UI Kit initialized with offline support."); + }) + .catch((error) => { + console.error("CometChat UI Kit initialization failed:", error); + }); +``` + + +```jsx +import { CometChatUIKit } from "@cometchat/chat-uikit-react-native"; +import { CometChat } from "@cometchat/chat-sdk-react-native"; + +const uiKitSettings = { + appId: "APP_ID", + authKey: "AUTH_KEY", + region: "REGION", + subscriptionType: CometChat.AppSettings.SUBSCRIPTION_TYPE_ALL_USERS, + offline: { + enabled: true, + mediaCaching: CometChat.MediaCachingMode.AUTO, + }, +}; + +CometChatUIKit.init(uiKitSettings) + .then(() => { + console.log("CometChat UI Kit initialized with offline support."); + }) + .catch((error) => { + console.error("CometChat UI Kit initialization failed:", error); + }); +``` + + + +`offline` accepts three forms: + +| Value | Result | +| --- | --- | +| `true` | Offline support with the SDK's default options. | +| An options object | The options you set, with defaults for the rest. Include `enabled: true` — without it, `enabled` defaults to `false` and offline support stays off. Every option in the [SDK configuration table](/sdk/react-native/offline-support#configuration-options) is supported. | +| A `CometChat.OfflineConfig` | Passed to the SDK as is. Create it with `enabled: true`. | + +If you leave `offline` out, or set it to `false`, offline support stays off and the UI Kit works exactly as before. + +If you initialize with `CometChatUIKit.initFromSettings()`, put the same value under `chatSDK.offline` in the settings object instead: + +```json +{ + "chatSDK": { + "offline": { "enabled": true } + } +} +``` + + + The UI Kit applies the `offline` setting only when the installed + `@cometchat/chat-sdk-react-native` exposes `CometChat.OfflineConfig`. With + an older SDK the setting is silently ignored and the UI Kit works online + only — check `CometChat.isOfflineEnabled()` after login if you need to be + sure. + + + + Whether offline support is actually running can change after init — for + example, CometChat can switch it off for your app from the server. The UI + Kit checks `CometChat.isOfflineEnabled()` each time it needs to; do the same + in your own code instead of reading back the value you configured. + + +## What Changes in the Components + +| Feature | Components | Behaviour | +| --- | --- | --- | +| Cached conversations | [Conversations](/ui-kit/react-native/conversations) | The list renders from the cache immediately, then updates with the server's list. Offline, the cached list stays on screen. | +| Cached messages | [Message List](/ui-kit/react-native/message-list) | A conversation opens with its cached messages, then updates with the server's page. | +| Sending while offline | [Message Composer](/ui-kit/react-native/message-composer), [Message List](/ui-kit/react-native/message-list) | A message sent offline appears in the list straight away with a clock icon and is queued — even if the app is closed. The SDK's "queued" notice is not shown as an error: the message keeps its clock icon. It is sent automatically when the connection returns, and its receipt updates as usual. | +| Pending and failed messages | [Message List](/ui-kit/react-native/message-list), [Conversations](/ui-kit/react-native/conversations) | A queued message shows a clock icon in its bubble, and a message that failed to send shows an error icon. A failed message is not retried automatically, including when the connection returns. A message discarded before it was sent — for example, when the offline cache is cleared — is final and is not sent. The conversation's row shows the same icon in place of the last message's receipt while it has a queued or failed message. The icons follow your receipt styles (`waitIcon`, `errorIcon`). | +| Cached media | [Message List](/ui-kit/react-native/message-list) | Images, videos, audio and files that were downloaded while online are shown from the device when offline (with `mediaCaching` set to `AUTO`, the default). The UI Kit follows `CometChat.getMediaCachingMode()`, so nothing is cached while offline support isn't running. | +| Attachment uploads | [Message Composer](/ui-kit/react-native/message-composer) | A file that failed to upload can be retried, and staged attachments are restored when the user reopens the conversation. | + +## Logging Out + +The SDK deletes the cached data, cached media and any queued messages when the user logs out, when a different user logs in, and when a login fails. Logging in again as the same user keeps the cache and any queued messages. You don't need to clear anything yourself. + +## Limitations + +- The cache only holds what the user has loaded while online, plus the changes picked up when the device reconnects. A user who has never logged in online has nothing to show offline. +- Only sending messages works offline. Actions such as editing, deleting, reacting and pinning need a connection. +- Search reads from the server only, so it doesn't work while offline. +- Failed messages are never retried automatically, and there is no built-in offline banner or "retry" action for a failed message. Use `CometChat.isOfflineEnabled()`, the SDK's [connection status](/sdk/react-native/connection-status) and its [retry methods](/sdk/react-native/offline-support#failed-messages-and-retry) if you want to build your own. + +--- + +## Next Steps + + + + Configuration options, queued sends and sync events + + + See how the components power each chat feature + + + Customize how messages are rendered + + + Initialize, log in and send messages with the UI Kit + +