diff --git a/docs.json b/docs.json index 96952f214..59081f3b6 100644 --- a/docs.json +++ b/docs.json @@ -3789,6 +3789,19 @@ "sdk/ios/pin-save-message" ] }, + { + "group": "Offline Support", + "pages": [ + "sdk/ios/offline-overview", + "sdk/ios/offline-enable", + "sdk/ios/offline-cached-data", + "sdk/ios/offline-send", + "sdk/ios/offline-sync", + "sdk/ios/offline-media-caching", + "sdk/ios/offline-encryption", + "sdk/ios/offline-reference" + ] + }, "sdk/ios/calling-overview", { "group": "Users", diff --git a/sdk/ios/additional-message-filtering.mdx b/sdk/ios/additional-message-filtering.mdx index cf70e4452..3ffb23aaf 100644 --- a/sdk/ios/additional-message-filtering.mdx +++ b/sdk/ios/additional-message-filtering.mdx @@ -159,6 +159,31 @@ To fetch pinned or saved messages, filter with `set(pinned:)` or `set(saved:)` r --- +## Offline support + +With offline support enabled (SDK 4.2.0 or later), `fetchPrevious` and `fetchNext` answer from the on-device cache first. The cache answers a page only when every filter on the request can be computed on the device. A single server-only filter makes the cached page empty, with no error, while the server phase still answers in full. + +| Builder method | Offline | +|---|---| +| `setCategory(category:)`, `set(categories:)` | Cached | +| `setType(type:)`, `set(types:)` | Cached | +| `setParentMessageId(parentMessageId:)` | Cached | +| `hideReplies(hide:)`, `hideDeletedMessages(hide:)` | Cached | +| `setTags(_:)` | Cached | +| `has(mentions:)`, `set(mentionedUIDs:)` | Cached | +| `has(attachments:)`, `set(attachmentTypes:)` | Cached | +| `has(reactions:)` | Cached | +| `hideMessagesFromBlockedUsers(_:)` | Cached | +| `set(unread:)` | Cached | +| `set(searchKeyword:)` | Server only, cached page is empty | +| `has(links:)` | Server only, cached page is empty | +| `updatesOnly(onlyUpdates:)` | Server only, cached page is empty | +| `setInteractionGoalCompletedOnly(_:)` | Server only, cached page is empty | + +`set(pinned:)` and `set(saved:)` are served from separate pinned and saved stores, not the message cache, so the other filters in this table do not apply to a pinned or saved read. + +`set(messageID:)`, `set(timeStamp:)`, `setUpdatedAfter(timeStamp:)`, `set(limit:)`, `set(uid:)` and `set(guid:)` are scope and pagination inputs, not filters, and do not change what the cache can answer. Setting both `set(uid:)` and `set(guid:)` (one sender's messages in a group) is answered by the server only. See [Show Cached Data](/sdk/ios/offline-cached-data). + ## Next Steps diff --git a/sdk/ios/ai-moderation.mdx b/sdk/ios/ai-moderation.mdx index c399f9291..e1d3f18b8 100644 --- a/sdk/ios/ai-moderation.mdx +++ b/sdk/ios/ai-moderation.mdx @@ -221,6 +221,10 @@ Always remove listeners when they're no longer needed (e.g., on component unmoun --- +## Offline support + +With offline support enabled (SDK 4.2.0 or later), the send overload with `onMessagePending` fires `onMessagePending` first, before the message reaches the server. That status is separate from the moderation status: the message is still sent with a `pending` moderation status, which `getModerationStatus()` reports. If you send without a connection, `onError` with code `ERROR_INTERNET_UNAVAILABLE` means the message is queued, not failed (with `autoSendPendingOnReconnect` on, the default). See [Send While Offline](/sdk/ios/offline-send). + ## Next Steps diff --git a/sdk/ios/all-real-time-delegates-listeners.mdx b/sdk/ios/all-real-time-delegates-listeners.mdx index 8bd1f8358..2d554710b 100644 --- a/sdk/ios/all-real-time-delegates-listeners.mdx +++ b/sdk/ios/all-real-time-delegates-listeners.mdx @@ -567,6 +567,19 @@ CometChat.removeAIAssistantListener("unique_listener_id") --- +## Offline listeners + +With offline support enabled (SDK 4.2.0 or later), `CometChatMessageDelegate` also receives `onMessagesSynced(_:)`. It fires after the SDK catches up on reconnect and carries 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`. + +Four more 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 | +|---|---|---| +| `OutboxListener` | Every outbox transition, app-wide: a message queued, sent or failed | [Send While Offline](/sdk/ios/offline-send) | +| `SyncListener` | Progress and outcome of each catch-up sync | [Stay in Sync](/sdk/ios/offline-sync) | +| `OfflineCacheResetListener` | The on-device cache was rebuilt, for example after the encryption key was lost | [Encrypt the Cache](/sdk/ios/offline-encryption) | +| `MediaDownloadListener` | A background media download finished or failed | [Media Caching](/sdk/ios/offline-media-caching) | + ## Next Steps diff --git a/sdk/ios/block-users.mdx b/sdk/ios/block-users.mdx index eaa4c5956..7799ba08c 100644 --- a/sdk/ios/block-users.mdx +++ b/sdk/ios/block-users.mdx @@ -204,6 +204,12 @@ Relevant fields to access on returned users: --- +## Offline support + +With offline support enabled (SDK 4.2.0 or later), `fetchNext` on a `BlockedUserRequest` has an overload that first delivers `onCachedUsers` from the SDK's own blocked-users list. The SDK refreshes that list on login and on each catch-up sync, and updates it when you call `blockUsers` or `unblockUsers`. The first cached page holds the whole list and later pages are empty. Then `onServerUsers` delivers the live page to reconcile by `uid`. + +The cached page is empty when no users are blocked or when `set(searchKeyword:)` is set. Offline, the read ends with `onError`, code `ERROR_INTERNET_UNAVAILABLE`, after the cached page. Blocking and unblocking need a connection and are not queued. See [Show Cached Data](/sdk/ios/offline-cached-data). + ## Next Steps diff --git a/sdk/ios/campaigns.mdx b/sdk/ios/campaigns.mdx index 830b8f56f..2bc4a1c60 100644 --- a/sdk/ios/campaigns.mdx +++ b/sdk/ios/campaigns.mdx @@ -275,6 +275,12 @@ request.fetchNext(onSuccess: { items in Call `fetchNext()` repeatedly for pagination. When the server has no more items, subsequent calls return an empty array. +### Offline support + +With offline support enabled (SDK 4.2.0 or later), `fetchNext` on a `NotificationFeedRequest` has an overload that first delivers `onCachedNotificationFeedItems` from the device (empty when nothing was recorded), then `onServerNotificationFeedItems` with the live page to reconcile by item `id`. Offline, the read ends with `onError`, code `ERROR_INTERNET_UNAVAILABLE`, after the cached page. + +The cached page replays what the server last returned for the same builder settings. `set(limit:)`, `set(readState:)`, `set(category:)`, `set(categoryId:)`, `set(channelId:)`, `set(tags:)`, `set(dateFrom:)` and `set(dateTo:)` are all part of the request. See [Show Cached Data](/sdk/ios/offline-cached-data). + ### NotificationFeedItem Fields | Field | Type | Description | diff --git a/sdk/ios/connection-status.mdx b/sdk/ios/connection-status.mdx index a604d934c..c648434e0 100644 --- a/sdk/ios/connection-status.mdx +++ b/sdk/ios/connection-status.mdx @@ -103,6 +103,10 @@ Always remove connection listeners when they're no longer needed. Failing to rem --- +## Offline support + +With offline support enabled (SDK 4.2.0 or later), your app keeps working without a connection. Cached chats are shown instantly, and messages you send with the `onMessagePending` overloads are queued and delivered automatically on reconnect. See [Show Cached Data](/sdk/ios/offline-cached-data) and [Send While Offline](/sdk/ios/offline-send). + ## Next Steps diff --git a/sdk/ios/delete-group.mdx b/sdk/ios/delete-group.mdx index e22cd161d..1048612ea 100644 --- a/sdk/ios/delete-group.mdx +++ b/sdk/ios/delete-group.mdx @@ -55,6 +55,10 @@ NSString *guid = @"cometchat-guid-101"; --- +## Offline support + +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 are removed from the on-device cache, and any messages still queued to it fail with reason `.discarded`. See [Send While Offline](/sdk/ios/offline-send). + ## Next Steps diff --git a/sdk/ios/delete-message.mdx b/sdk/ios/delete-message.mdx index c3592ec7f..24ab9df85 100644 --- a/sdk/ios/delete-message.mdx +++ b/sdk/ios/delete-message.mdx @@ -104,6 +104,10 @@ You must be the message sender or a group admin/moderator to delete a message. --- +## Offline support + +Deleting a message needs a connection. With offline support enabled, a delete is not queued for later. After `onSuccess`, 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/ios/offline-send). + ## Next Steps diff --git a/sdk/ios/edit-message.mdx b/sdk/ios/edit-message.mdx index 7924d4507..3ba1d8c13 100644 --- a/sdk/ios/edit-message.mdx +++ b/sdk/ios/edit-message.mdx @@ -121,6 +121,10 @@ You must be the message sender or a group admin/moderator to edit a message. --- +## Offline support + +Editing a message needs a connection. With offline support enabled, an edit is not queued for later. After `onSuccess`, 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/ios/offline-send). + ## Next Steps diff --git a/sdk/ios/group-kick-member.mdx b/sdk/ios/group-kick-member.mdx index 809698738..1c962c75e 100644 --- a/sdk/ios/group-kick-member.mdx +++ b/sdk/ios/group-kick-member.mdx @@ -172,6 +172,21 @@ let bannedGroupMembersRequest = BannedGroupMembersRequest.BannedGroupMembersRequ +### Set Scopes + +Filters banned members by scope. + + + +```swift +let bannedGroupMembersRequest = BannedGroupMembersRequest.BannedGroupMembersRequestBuilder(guid: "cometchat-guid-1") + .set(limit: 30) + .set(scopes: ["admin", "moderator"]) + .build() +``` + + + Once configured, call `build()` to create the request, then `fetchNext()` to retrieve banned members. @@ -211,6 +226,18 @@ BannedGroupMembersRequest *bannedGroupMemberRequest = [[[[BannedGroupMembersRequ +### Offline support + +With offline support enabled (SDK 4.2.0 or later), `fetchNext` on a `BannedGroupMembersRequest` has an overload that first delivers `onCachedBannedGroupMembers` from the device, then `onServerBannedGroupMembers` with the live page to reconcile by `uid`. Offline, the read ends with `onError`, code `ERROR_INTERNET_UNAVAILABLE`, after the cached page. A request that uses a server-only filter records nothing, so its cached page is always empty. + +| Builder method | Offline | +|---|---| +| `set(limit:)` | Cached | +| `set(scopes:)` | Cached | +| `set(searchKeyword:)` | Server only | + +Kicking, banning and unbanning need a connection and are not queued. See [Show Cached Data](/sdk/ios/offline-cached-data). + ## Real-Time Kick/Ban/Unban Events Implement these `CometChatGroupDelegate` methods to receive real-time notifications: diff --git a/sdk/ios/mentions.mdx b/sdk/ios/mentions.mdx index 5d423e387..8229e4cb1 100644 --- a/sdk/ios/mentions.mdx +++ b/sdk/ios/mentions.mdx @@ -203,6 +203,10 @@ let wasMentioned = message.mentionedMe // Returns Bool --- +## Offline support + +With offline support enabled (SDK 4.2.0 or later), a message with mentions is sent like any other message: use the overload with `onMessagePending`, `onMessageSent` and `onError`. Offline, `onError` with code `ERROR_INTERNET_UNAVAILABLE` means the message is queued, not failed (with `autoSendPendingOnReconnect` on, the default). Mention fetches run in two phases: `onCachedMessages` delivers the cached page first (even when empty), then `onServerMessages`. See [Send While Offline](/sdk/ios/offline-send) and [Show Cached Data](/sdk/ios/offline-cached-data). + ## Next Steps diff --git a/sdk/ios/offline-cached-data.mdx b/sdk/ios/offline-cached-data.mdx new file mode 100644 index 000000000..4249d1722 --- /dev/null +++ b/sdk/ios/offline-cached-data.mdx @@ -0,0 +1,204 @@ +--- +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 iOS SDK." +--- + +{/* TL;DR for Agents and Quick Reference */} + + +```swift +// Every read fires up to two phases: cached first (instant), then server. +let request = MessagesRequest.MessageRequestBuilder() + .set(uid: "UID") + .set(limit: 30) + .build() + +request.fetchPrevious(onCachedMessages: { messages in + // Phase 1: local cache. Fires first, even when empty. Render immediately. +}, onServerMessages: { messages in + // Phase 2: server. Replaces the same slice — reconcile, don't append. Never fires offline. +}, onError: { error in + // Offline ends here, after the cached page, with ERROR_INTERNET_UNAVAILABLE. +}) + +// Single items (additive two-phase overloads) +CometChat.getConversation(conversationWith: "UID", conversationType: .user, listener: conversationDetailsListener) +CometChat.getMessageDetails(messageId, listener: messageDetailsListener) +``` + + + +With offline support enabled, every list and lookup the SDK already offers answers 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 + +Each read 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. + + + +Two rules follow from this: + +- **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`, code `ERROR_INTERNET_UNAVAILABLE`. A real server error reaches `onError` unchanged. The call always settles, as long as you wait for each fetch to return before starting the next one on the same request object. +- **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. + +All callbacks are delivered on the main thread. + + +With offline support **off**, these overloads behave like the classic call: the cached phase never fires, and the server result arrives in `onServer…` instead of `onSuccess`. Nothing changes until you enable offline (see [Enable Offline Support](/sdk/ios/offline-enable)). Requires SDK **4.2.0** or later. + + + +Run one fetch at a time on a request object. A second fetch while the previous one is still in flight fires no cached phase. On `MessagesRequest`, `ThreadsRequest` and `ReactionsRequest` it fails with `ERROR_REQUEST_IN_PROGRESS`. On the user, group, group-member and banned-member lists it returns an empty server result. Wait for the first fetch to finish, or create a new request. + + +## Read messages + +`MessagesRequest` supports both directions. `fetchPrevious` pages toward older messages; `fetchNext` pages toward newer ones. Both have a two-phase overload. + +```swift +let request = MessagesRequest.MessageRequestBuilder() + .set(uid: "UID") + .set(limit: 30) + .build() + +request.fetchPrevious(onCachedMessages: { messages in + // Render the cached page immediately. +}, onServerMessages: { messages in + // Reconcile this slice against the cached page, keyed by message id. +}, onError: { error in + print("Fetch failed: \(error?.errorCode ?? "")") +}) +``` + +The classic `fetchPrevious(onSuccess:onError:)` and `fetchNext(onSuccess:onError:)` are unchanged and never deliver a cached result. + +## Read conversations + +`ConversationRequest` pages in one direction with `fetchNext`. + +```swift +let request = ConversationRequest.ConversationRequestBuilder(limit: 30).build() + +request.fetchNext(onCachedConversations: { conversations in + // Render the cached list immediately. +}, onServerConversations: { conversations in + // Reconcile, keyed by conversation id. +}, onError: { error in + print("Fetch failed: \(error?.errorCode ?? "")") +}) +``` + +## Read the other lists + +Every remaining list read gains the same two-phase overload, with `onCached…` / `onServer…` closures of the matching type plus `onError`. + +| Request | Fetch method(s) | Cached / server closure labels | +|---|---|---| +| `MessagesRequest` | `fetchPrevious`, `fetchNext` | `onCachedMessages` / `onServerMessages` | +| `ConversationRequest` | `fetchNext` | `onCachedConversations` / `onServerConversations` | +| `ThreadsRequest` | `fetchNext` | `onCachedThreads` / `onServerThreads` | +| `UsersRequest` | `fetchNext` | `onCachedUsers` / `onServerUsers` | +| `BlockedUserRequest` | `fetchNext` | `onCachedUsers` / `onServerUsers` | +| `GroupsRequest` | `fetchNext` | `onCachedGroups` / `onServerGroups` | +| `GroupMembersRequest` | `fetchNext` | `onCachedGroupMembers` / `onServerGroupMembers` | +| `BannedGroupMembersRequest` | `fetchNext` | `onCachedBannedGroupMembers` / `onServerBannedGroupMembers` | +| `ReactionsRequest` | `fetchPrevious`, `fetchNext` | `onCachedReactions` / `onServerReactions` | +| `NotificationFeedRequest` | `fetchNext` | `onCachedNotificationFeedItems` / `onServerNotificationFeedItems` | + +The call shape is identical to the conversations example above — swap the request and the closure labels. + +```swift +let request = UsersRequest.UsersRequestBuilder() + .set(limit: 30) + .build() + +request.fetchNext(onCachedUsers: { users in + // render +}, onServerUsers: { users in + // reconcile, keyed by uid +}, onError: { error in + print("Fetch failed: \(error?.errorCode ?? "")") +}) +``` + + +For `ReactionsRequest`, `fetchPrevious` and `fetchNext` are recorded and replayed independently — each direction keeps its own cached pages. Users, groups, members, banned members, reactions, and the notification feed are served from a per-request snapshot rather than a shared store: the cached phase replays what the server returned online for the **same builder settings**, so a list you never fetched online has an empty cached page. Per-builder details are in the [Configuration & API Reference](/sdk/ios/offline-reference). + + + +`BlockedUserRequest` is the exception: its cached page is the SDK's own blocked-users list, which the SDK refreshes on login and on each catch-up sync and updates when you call `blockUsers` or `unblockUsers`. The first cached page holds the whole list and later pages are empty. + + +## Read a single item + +`getConversation` and `getMessageDetails` each add a two-phase overload. Unlike the list reads, the cached callback fires **only when the item is cached** — never with an empty or nil object. A cache miss goes straight to the server phase online, or straight to `onError` offline. + +`getConversation` takes a `ConversationDetailsListener`; `getMessageDetails` takes a `MessageDetailsListener`. + +```swift +final class ConversationLoader: NSObject, ConversationDetailsListener { + + func onCachedConversation(_ conversation: Conversation) { + // Fires only on a cache hit. + } + + func onServerConversation(_ conversation: Conversation?) { + // Server copy, written through before delivery. + } + + func onError(_ error: CometChatException?) { + print("Lookup failed: \(error?.errorCode ?? "")") + } +} + +let loader = ConversationLoader() +CometChat.getConversation(conversationWith: "UID", conversationType: .user, listener: loader) + +final class MessageLoader: NSObject, MessageDetailsListener { + + func onCachedMessage(_ message: BaseMessage) { + // Fires only on a cache hit. + } + + func onServerMessage(_ message: BaseMessage?) { + // Server copy. + } + + func onError(_ error: CometChatException?) { + print("Lookup failed: \(error?.errorCode ?? "")") + } +} + +let messageLoader = MessageLoader() +CometChat.getMessageDetails(messageId, listener: messageLoader) +``` + + +The SDK holds the `ConversationDetailsListener` or `MessageDetailsListener` until the read ends, so you do not need to keep your own reference for a single lookup. + + +## 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 predicate makes the entire cached phase return an empty list — with no error and no warning — while the server phase still answers in full. + +The most common server-only predicate is `set(searchKeyword:)`, which blanks the cached phase on every request. Other examples on `MessagesRequest` are `has(links:)`, `updatesOnly(onlyUpdates:)` and `setInteractionGoalCompletedOnly(_:)`. Reading one sender's messages in a group (`set(uid:)` together with `set(guid:)`) is also answered by the server only. On the entity lists, a keyword search or a presence filter (`UsersRequest.set(status:)`, `GroupMembersRequest.set(status:)`) is never snapshotted, so it has no cached phase either. + + +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 of what the cache answers lives in the [Configuration & API Reference](/sdk/ios/offline-reference). + diff --git a/sdk/ios/offline-enable.mdx b/sdk/ios/offline-enable.mdx new file mode 100644 index 000000000..97d01b32a --- /dev/null +++ b/sdk/ios/offline-enable.mdx @@ -0,0 +1,112 @@ +--- +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 iOS SDK." +--- + +{/* TL;DR for Agents and Quick Reference */} + + +```swift +// 1. Build an OfflineConfig (off by default — call setEnabled(true)). +let offlineConfig = OfflineConfig.Builder() + .setEnabled(true) + .build() +// Swift shorthand: let offlineConfig = OfflineConfig(enabled: true) + +// 2. Attach it to AppSettings. +let appSettings = AppSettings.AppSettingsBuilder() + .setRegion(region: "REGION") + .setOfflineConfig(offlineConfig) + .build() + +// 3. Pass the settings to init. +CometChat.init(appId: "APP_ID", appSettings: appSettings, onSuccess: { _ in }, onError: { _ in }) + +// 4. Verify it is active for the logged-in user. +let active = CometChat.isOfflineEnabled() // Bool +``` + + + +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. This page covers the minimal setup; for every tunable option see the [Configuration & API Reference](/sdk/ios/offline-reference). + + + + +Offline support requires CometChat iOS SDK **4.2.0** or later. Update the dependency: + + + +```ruby +pod 'CometChatSDK', '4.2.0' +``` + + +Set the `https://github.com/cometchat/chat-sdk-ios.git` dependency rule to `Up to Next Major Version` with version `4.2.0`. + + + +If you are new to the SDK, complete the [standard setup](/sdk/ios/setup) first; the steps below only add offline configuration to your existing `init`. + + + +Create an `OfflineConfig` and enable it. Every other field has a default, so set only what you need. In Swift you can use the initializer with default arguments. The `Builder` works from both Swift and Objective-C. + + + +```swift +let offlineConfig = OfflineConfig(enabled: true) +``` + + +```swift +let offlineConfig = OfflineConfig.Builder() + .setEnabled(true) + .build() +``` + + + + + +Pass the config to `AppSettingsBuilder.setOfflineConfig(_:)`. Without it, `AppSettings` keeps `OfflineConfig.default`, which has offline switched off. + +```swift +let appSettings = AppSettings.AppSettingsBuilder() + .setRegion(region: "REGION") + .setOfflineConfig(offlineConfig) + .build() +``` + + + +Pass the settings to `CometChat.init` as you do today. No other call changes. + +```swift +CometChat.init(appId: "APP_ID", appSettings: appSettings, onSuccess: { isSuccess in + print("Initialization completed successfully") +}, onError: { error in + print("Initialization failed: \(error.errorDescription)") +}) +``` + + + +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 encrypted cache cannot open. Read it at the moment of each decision and keep no copy of the result. + +```swift +let active = CometChat.isOfflineEnabled() +print("Offline support active: \(active)") +``` + + +It is also `false` while the cache cannot run encrypted on this device and you chose `.onlineOnly` (see [Encrypt the Cache](/sdk/ios/offline-encryption)). It is already `true` by the time any `onLoginSuccess` observer runs. + + + + + +## Next steps + +Offline support is now active for the logged-in user. Continue with [Show Cached Data](/sdk/ios/offline-cached-data) to adopt the two-phase read overloads, and [Send While Offline](/sdk/ios/offline-send) for the durable outbox. Tune caps, media, and encryption from the [Configuration & API Reference](/sdk/ios/offline-reference). diff --git a/sdk/ios/offline-encryption.mdx b/sdk/ios/offline-encryption.mdx new file mode 100644 index 000000000..517aa3c6e --- /dev/null +++ b/sdk/ios/offline-encryption.mdx @@ -0,0 +1,145 @@ +--- +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 iOS SDK." +--- + +{/* TL;DR for Agents and Quick Reference */} + + +```swift +// 1. Nothing to add: the SDK bundles SQLCipher (no extra dependency). + +// 2. Turn encryption on in OfflineConfig (off by default) +let offlineConfig = OfflineConfig.Builder() + .setEnabled(true) + .setEncryption(true) // encrypt the cache database at rest + .setEncryptionFailureBehavior(.onlineOnly) // default; never writes plaintext + .build() + +// 3. Inspect what encryption is actually doing (a [String: Any] dictionary) +let status = CometChat.getOfflineEncryptionStatus() +status["enabled"] // Bool - what was configured (false while offline is off for the app) +status["atRest"] // String - "SQLCIPHER" or "NONE" +status["keyStorage"] // String - "KEYSTORE" (the iOS Keychain) or "NONE" +status["cipherAvailable"] // Bool - is the SQLCipher engine present in this build? +status["cipher"] // String - engine cipher_version once proven, else NSNull +status["leakage"] // String - 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 call `setEncryption(true)`. The minimum SDK version is **4.2.0**. + + +Encryption covers the **message database only**. Downloaded and outgoing media files (photos, voice notes, documents) stay as ordinary unencrypted files on disk, because their local paths are handed to native image and video loaders. See [What is and isn't encrypted](#what-is-and-isn-t-encrypted). + + +## No extra dependency + +iOS needs no separate cipher dependency. The SDK ships its own SQLCipher build and keeps it isolated from your app, so it does not clash with an app's own SQLite, SQLCipher, GRDB or FMDB. There is nothing to add to your `Podfile` or Swift package. + +## Turn on encryption + +Set `setEncryption(true)` on the `OfflineConfig` you pass to the SDK during initialization (see [Enable Offline Support](/sdk/ios/offline-enable)). + + + +```swift +let offlineConfig = OfflineConfig.Builder() + .setEnabled(true) + .setEncryption(true) + .setEncryptionFailureBehavior(.onlineOnly) + .build() +``` + + +```swift +let offlineConfig = OfflineConfig( + enabled: true, + encryption: true, + encryptionFailureBehavior: .onlineOnly +) +``` + + + +## 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 — the cipher engine is not linked into the build, for example. Because the engine ships inside the SDK this should not happen in a normal app, but the setting is there so you decide the outcome rather than the SDK. It governs only that case: a transient failure (such as a locked Keychain) still retries, and a lost or wrong key self-heals by wiping and rebuilding the cache **encrypted**. + +| `CometChat.EncryptionFailureBehavior` | Behavior | Writes plaintext? | +|---|---|---| +| `.onlineOnly` *(default)* | Do not open a local cache at all — the session runs server-only. Makes a setup mistake visible: offline simply does not work until it is fixed. | No | +| `.plaintextFallback` | Open an **unencrypted** cache so offline keeps working, and log the downgrade at error level. | Yes | + + +`.plaintextFallback` 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 `.onlineOnly` if cached content must never be written in the clear. + + +While the cache is dormant for this reason, `CometChat.isOfflineEnabled()` returns `false`. + +## Key storage + +You do not supply or manage the encryption key. The SDK generates a random 256-bit key per user (scoped to the app ID and the logged-in user's UID) and keeps it in the **iOS Keychain**, with the `AfterFirstUnlockThisDeviceOnly` accessibility class — available after the first unlock, and never synced or restored to another device. The key 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 a `[String: Any]` dictionary describing what encryption is **actually** doing — never what was merely requested. It is safe to call at any time and never returns the key or any part of it. + +```swift +let status = CometChat.getOfflineEncryptionStatus() +print("enabled=\(status["enabled"] ?? "")", + "atRest=\(status["atRest"] ?? "")", + "keyStorage=\(status["keyStorage"] ?? "")", + "cipherAvailable=\(status["cipherAvailable"] ?? "")", + "cipher=\(status["cipher"] ?? "")") +``` + +| Key | Value type | Meaning | +|---|---|---| +| `enabled` | `Bool` | What was **configured** (`OfflineConfig.encryption`), not what was achieved. `false` whenever offline is switched off for the app. | +| `atRest` | `String` | Coverage actually achieved: `"SQLCIPHER"` once an encrypted open is proven, otherwise `"NONE"`. | +| `keyStorage` | `String` | `"KEYSTORE"` when the key was resolved from the iOS Keychain; `"NONE"` when this session holds no key. Read `NONE` as "no key in use", not "no key exists". | +| `cipherAvailable` | `Bool` | Whether the SQLCipher engine is present and linked in this process. `false` when encryption is off. | +| `cipher` | `String` / `NSNull` | The engine's `cipher_version` once an encrypted open is proven, else `NSNull`. | +| `leakage` | `String` | What encryption at rest never covers — the downloaded media files stored outside the database. | + +The string values are also available as constants on `CometChat.OfflineEncryptionTier` (for example `OfflineEncryptionTier.atRestSqlcipher`). + + +The single most useful support signal is `enabled == true` with `atRest == "NONE"`: encryption was switched on but is not in effect — the cache is dormant, still opening, or was opened unencrypted under `.plaintextFallback`. + + +## What is and isn't encrypted + +| Data | Encrypted at rest? | +|---|---| +| Message database (messages, conversations, metadata, the pending-send outbox) | Yes, when `atRest` is `"SQLCIPHER"` | +| 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.localPath` is public API and those paths are passed straight to native media loaders. "Encrypted at rest" here always means the message database, never the media cache. Encryption is not end-to-end: the server still sees message content. + +## 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 `CometChat.offlinePendingCountUnknown` (`-1`). When the reset is `.encryptionChanged` 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: + +```swift +final class CacheResetObserver: NSObject, OfflineCacheResetListener { + + func onOfflineCacheReset(_ reason: CometChat.OfflineCacheResetReason, _ pendingMessagesLost: Int) { + print("Cache reset: \(reason), pending lost=\(pendingMessagesLost)") + } +} + +let resetObserver = CacheResetObserver() // keep a strong reference +CometChat.addOfflineCacheResetListener("LISTENER_ID", resetObserver) +``` + +The encryption-related reasons are `.keyLost`, `.wrongKey`, `.corrupted`, `.escalated`, and `.encryptionChanged` (you turned encryption on or off between launches, which also discards any queued messages). `pendingMessagesLost` is `CometChat.offlinePendingCountUnknown` (`-1`) when the unsent messages could not be counted. See [Stay in Sync](/sdk/ios/offline-sync) for the full cache-reset flow and the complete list of reasons, and the [API Reference](/sdk/ios/offline-reference) for every field and enum. + + +Switching `encryption` on or off between launches rebuilds the cache in the new format. If the old cache file cannot be deleted at that moment, offline support is unavailable for the rest of that app session. + diff --git a/sdk/ios/offline-media-caching.mdx b/sdk/ios/offline-media-caching.mdx new file mode 100644 index 000000000..252502b1e --- /dev/null +++ b/sdk/ios/offline-media-caching.mdx @@ -0,0 +1,148 @@ +--- +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 iOS SDK." +--- + +{/* TL;DR for Agents and Quick Reference */} + + +```swift +// Set the media caching mode at init (fixed for the session) +let offlineConfig = OfflineConfig.Builder() + .setEnabled(true) + .setMediaCaching(.auto) // .auto (default) or .off + .setMaxMediaCacheSize(500 * 1024 * 1024) // bytes; 0 = unlimited + .build() + +// Read the mode in effect +let mode = CometChat.getMediaCachingMode() // .off when offline is not active + +// Resolve cached bytes for an attachment +let attachment = mediaMessage.attachment +let localPath = attachment?.localPath // hydrated automatically on reads +let cached = CometChat.getCachedMediaPath(attachment!.fileUrl) // nil if not cached + +// Download on demand +CometChat.downloadMedia(url: attachment!.fileUrl, messageServerId: mediaMessage.id, + onProgress: { percent in }, + onSuccess: { localPath in }, + onError: { error in }) + +// Observe background (.auto) downloads +CometChat.addMediaDownloadListener("MEDIA_LISTENER", mediaObserver) // MediaDownloadListener +``` + + + +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`). See [Enable Offline Support](/sdk/ios/offline-enable). + + +## 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. + +| `CometChat.MediaCachingMode` | Behavior | +|---|---| +| `.auto` (default) | Incoming media bytes are eagerly downloaded and cached, 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 `fileUrl`) 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. + +```swift +let offlineConfig = OfflineConfig.Builder() + .setEnabled(true) + .setMediaCaching(.auto) + .setMaxMediaCacheSize(500 * 1024 * 1024) + .build() +``` + +Read the mode in effect at runtime with `CometChat.getMediaCachingMode()`. It returns `.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/ios/offline-sync) is cached as **metadata only** and renders as a placeholder; its bytes download 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: + +```swift +if let attachment = mediaMessage.attachment, let localPath = attachment.localPath { + // Non-nil when bytes are cached. Render directly from disk — no network fetch. +} +``` + +To look up the cached path explicitly, call `getCachedMediaPath` with the attachment's URL. It returns `nil` when the bytes are not cached (call `downloadMedia` to fetch them). + +```swift +let path = CometChat.getCachedMediaPath(attachment.fileUrl) // nil if not cached +``` + + +Each attachment of a multi-attachment message resolves independently. An attachment with no cached bytes keeps `localPath == nil` 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. Pass the attachment's `fileUrl` and the message's server id. The SDK resolves the bytes in order: the cache, then the URL, then, if the server rejects the URL as expired or forbidden (HTTP 401, 403 or 410), one re-signed URL (looked up with the message id). + +```swift +CometChat.downloadMedia(url: attachment.fileUrl, messageServerId: mediaMessage.id, onProgress: { percent in + // 0...100, best-effort. +}, onSuccess: { localPath in + // Bytes cached; render from localPath. +}, onError: { error in + print("Download failed: \(error.errorCode)") +}) +``` + +Without progress, use `downloadMedia(url:messageServerId:onSuccess:onError:)`. + +`onProgress` reports `0...100`, best-effort and never going backwards. 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 `onSuccess`. Progress never blocks or fails the download. Dispatch to the main queue before updating UI from these closures. + +`onError` carries an offline error code, never a raw HTTP status: + +| Error code | Meaning | What to do | +|---|---|---| +| `ERROR_OFFLINE_MEDIA_NOT_CACHED` | The bytes are not cached and the device is offline. | Show a placeholder; do not retry until the connection returns. | +| `ERROR_OFFLINE_MEDIA_UNRESOLVABLE` | The cache missed, the download failed, and re-signing produced nothing usable (or the URL was invalid). | A retry is allowed but likely to fail. | +| `ERROR_OFFLINE_DISABLED` | Offline support is not enabled, or nobody is logged in. | Load from the URL as before. | + +## Observe background downloads + +In `.auto` mode, incoming media downloads in the background with no per-call callback. Register a `MediaDownloadListener` to learn when the bytes land (or fail) so a UI tile can refresh. Callbacks fire on the main thread. + +```swift +final class MediaObserver: NSObject, MediaDownloadListener { + + func onMediaDownloaded(_ messageId: String, _ url: String, _ localPath: String) { + // messageId: the message's server id (or its local id if not yet sent) + // url: the attachment URL without its query string or fragment + // localPath: where the bytes landed + } + + func onMediaDownloadFailed(_ attachment: Attachment?, _ error: CometChatException) { } +} + +let mediaObserver = MediaObserver() // keep a strong reference +CometChat.addMediaDownloadListener("MEDIA_LISTENER", mediaObserver) +``` + +Remove the listener with `CometChat.removeMediaDownloadListener("MEDIA_LISTENER")`. The background `MediaDownloadListener` 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. The outgoing copies of un-sent messages and in-progress uploads are never evicted and do not count toward the budget, so the cache can briefly exceed `maxMediaCacheSize`. Bulk list loads and syncs do not refresh an item's recency. An outgoing media send that cannot fit the budget fails with `ERROR_OFFLINE_MEDIA_BYTE_OVERFLOW` (see [Send While Offline](/sdk/ios/offline-send#queue-limits)). + +Media is stored as files in durable, app-private storage; 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/ios/offline-reference) for the full set of options. diff --git a/sdk/ios/offline-overview.mdx b/sdk/ios/offline-overview.mdx new file mode 100644 index 000000000..43b52bf4c --- /dev/null +++ b/sdk/ios/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 iOS SDK." +--- + +{/* TL;DR for Agents and Quick Reference */} + + +```swift +// Offline support is OFF by default. Turn it on at init (SDK 4.2.0 or later). +let offlineConfig = OfflineConfig.Builder() + .setEnabled(true) + .build() + +let appSettings = AppSettings.AppSettingsBuilder() + .setRegion(region: "REGION") + .setOfflineConfig(offlineConfig) // or OfflineConfig(enabled: true) for defaults + .build() + +CometChat.init(appId: "APP_ID", appSettings: appSettings, onSuccess: { _ in }, onError: { _ in }) + +// Is offline support active right now for the logged-in user? +let active = CometChat.isOfflineEnabled() // Bool +``` + + + +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/ios/offline-enable)). This page explains the mental model; the other pages show the exact APIs. + + +Offline support ships in CometChat iOS SDK **4.2.0** or later. + + +## How it works + +Three ideas carry the whole feature. + + + +Every read 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/ios/offline-cached-data). + + +A message sent while offline is saved to a durable outbox and reported as pending instead of failing. It survives app restarts and is sent, in the order it was composed, when the connection returns. See [Send While Offline](/sdk/ios/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/ios/offline-sync). + + + +```mermaid +flowchart LR + A[App read] --> B[(Local cache)] + B -- cached result first --> A + B --> C{Online?} + C -- yes --> D[Server] + D -- fresh result --> A + C -- no --> E[onError:\nERROR_INTERNET_UNAVAILABLE] + F[Send while offline] --> G[(Durable outbox)] + G -- on reconnect --> D +``` + +## What changes in your app + +With offline support enabled, reads fire **twice** and sends gain a pending stage. To receive these cached and pending results you call the new overloads of the calls you already use: + +- Reads: `fetchNext` / `fetchPrevious` with `onCached…`, `onServer…` and `onError` closures, `getConversation(conversationWith:conversationType:listener:)` with a `ConversationDetailsListener`, and `getMessageDetails(_:listener:)` with a `MessageDetailsListener`. +- Sends: `sendTextMessage`, `sendMediaMessage`, `sendCustomMessage` and `sendInteractiveMessage` with `onMessagePending`, `onMessageSent` and `onError` closures. + +The existing `onSuccess` / `onError` overloads are unchanged and keep working exactly as before: they receive only the server result, and a send through them is never queued. With offline support off, the new overloads behave like the classic ones: the cached and pending callbacks never fire and the server result arrives in `onServer…` / `onMessageSent`. + +## One cache per user + +The SDK keeps a single local cache for the user who is logged in. + +- 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 deletes 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/ios/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 cached content, and up to **500 MB** of cached media. Older entries are evicted first as these limits are reached. Unsent messages, saved messages and pinned rows are never evicted by these limits. Every limit is tunable — see the [Configuration & API Reference](/sdk/ios/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/ios/offline-reference.mdx b/sdk/ios/offline-reference.mdx new file mode 100644 index 000000000..28b948e81 --- /dev/null +++ b/sdk/ios/offline-reference.mdx @@ -0,0 +1,215 @@ +--- +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 iOS SDK." +--- + +{/* TL;DR for Agents and Quick Reference */} + + +```swift +// Build the config (every field has a default; set only what you change) +let offlineConfig = OfflineConfig.Builder() + .setEnabled(true) + .setMaxConversations(100) + .setMaxMessagesPerConversation(500) + .setMaxPendingMessages(50) + .setMediaCaching(.auto) + .setEncryption(false) + .build() + +// Introspection / control +CometChat.isOfflineEnabled() // Bool +CometChat.getMediaCachingMode() // CometChat.MediaCachingMode +CometChat.getOfflineEncryptionStatus() // [String: Any] +CometChat.getSyncState() // CometChat.SyncState +CometChat.getLastSyncTimestamp() // Int (server epoch seconds) +CometChat.getCacheSize() // Int64 (bytes) - call off the main thread +CometChat.getPendingMessages(onSuccess: { _ in }, onError: { _ in }) +CometChat.forceSyncNow() // or forceSyncNow(onSuccess:onError:) +CometChat.clearOfflineCache() +``` + + + +This page is the full surface of CometChat's offline support on iOS: 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/ios/offline-enable), [Show Cached Data](/sdk/ios/offline-cached-data), [Send While Offline](/sdk/ios/offline-send), [Stay in Sync](/sdk/ios/offline-sync), [Media Caching](/sdk/ios/offline-media-caching), and [Encrypt the Cache](/sdk/ios/offline-encryption). + +## OfflineConfig options + +Build with `OfflineConfig.Builder`, or with the Swift initializer `OfflineConfig(enabled:encryption:…)`, whose arguments all default to the values below. The resulting `OfflineConfig` is immutable. Byte caps and the TTL treat `0` or less as **unlimited** (that dimension's eviction is disabled); the count caps are clamped and have no "unlimited". + +| Builder setter | Type | Default | Range / clamp | Effect | +|---|---|---|---|---| +| `setEnabled(_:)` | `Bool` | `false` | — | Master gate for the whole offline engine. With it off the SDK behaves exactly as before. | +| `setCacheDurationDays(_:)` | `Int` | `0` (unlimited) | `>= 0`; negative treated as `0` | Days to keep a conversation that has had no new message and has not been opened. `0` = no age-based prune; eviction is by the byte/count caps instead. | +| `setMaxStorageSize(_:)` | `Int64` bytes | `262144000` (250 MB) | `0` = unlimited; negative treated as `0` | Byte budget for cached content: messages, entity snapshots and stored lists. The outbox is excluded. | +| `setMaxMessagesPerConversation(_:)` | `Int` | `500` | clamped `[1, 1000]`; `0`/negative/over → `1000` | Per-conversation cached-message window. | +| `setMaxConversations(_:)` | `Int` | `100` | clamped `[1, 1000]`; `0`/negative/over → `1000` | Cached-conversation cap. | +| `setMaxPendingMessages(_:)` | `Int` | `50` | clamped `[1, 100]`; `0`/negative/over → `100` | Pending-send outbox capacity. | +| `setAutoSendPendingOnReconnect(_:)` | `Bool` | `true` | — | Auto-flush queued pending sends on reconnect. Gates only the outbox flush; the reconnect sync sweep runs regardless. | +| `setPendingQueueOverflowPolicy(_:)` | `CometChat.PendingMessageOverflowPolicy` | `.rejectNewest` | — | What the outbox does when it is full. | +| `setMaxMediaCacheSize(_:)` | `Int64` bytes | `524288000` (500 MB) | `0` = unlimited; negative treated as `0` | Media-only byte budget (LRU). | +| `setMediaCaching(_:)` | `CometChat.MediaCachingMode` | `.auto` | — | Receive-side media mode. Fixed at init. | +| `setEncryption(_:)` | `Bool` | `false` | — | Encrypt the cache database at rest. See [Encrypt the Cache](/sdk/ios/offline-encryption). | +| `setEncryptionFailureBehavior(_:)` | `CometChat.EncryptionFailureBehavior` | `.onlineOnly` | — | What happens when encryption cannot run at all. | +| `setMaxSnapshotQueries(_:)` | `Int` | `100` | `0`/negative → `100` | Most entity-list snapshots kept for offline replay, across users, groups, group members, banned members, reactions and the notification feed; LRU. | +| `setSnapshotTtlDays(_:)` | `Int` | `7` | `0`/negative → `7` | Days an entity-list snapshot is kept. | + +Attach the config at init via `AppSettings.AppSettingsBuilder.setOfflineConfig(_:)` — see [Enable Offline Support](/sdk/ios/offline-enable). Each value is readable on the built `OfflineConfig` (for example `offlineConfig.maxConversations`), and `AppSettings.offlineConfig` exposes the active config read-only. + +## Public methods on CometChat + +| Method | Returns | Purpose | +|---|---|---| +| `isOfflineEnabled()` | `Bool` | Whether offline support is active for the current session. | +| `getMediaCachingMode()` | `CometChat.MediaCachingMode` | The effective media caching mode (`.off` whenever offline is not active). | +| `getOfflineEncryptionStatus()` | `[String: Any]` | What encryption at rest is actually doing. Compare its strings against the `CometChat.OfflineEncryptionTier` constants. | +| `getPendingMessages(onSuccess:onError:)` | `Void` | All queued/failed messages in the outbox. The synchronous `getPendingMessages()` is deprecated. | +| `retryMessage(_ localMessageId: String, onSuccess:onError:)` | `Void` | Retry one unsent message by its local id. Swift-only. | +| `retryMessage(_ message: BaseMessage)` | `Void` | Fire-and-forget retry that takes the message. | +| `retryAllFailedMessages()` | `Void` | Retry every transient-failed message in the outbox. No callback. | +| `discardLocalMessage(_ localMessageId: String, onSuccess:onError:)` | `Void` | Remove one unsent message from the outbox. Swift-only. | +| `forceSyncNow()` | `Void` | Request a catch-up sync. Silently dropped if a sync is already running or one ran within the cooldown window, and a no-op when offline is not active. Use the callback overload to learn why a sync did not start. | +| `forceSyncNow(onSuccess:onError:)` | `Void` | Same, with callbacks that report whether the sync was accepted. | +| `getSyncState()` | `CometChat.SyncState` | Whether a catch-up sweep is currently running. | +| `getLastSyncTimestamp()` | `Int` | Server time of the last successful sync, in epoch seconds. | +| `clearOfflineCache()` | `Void` | Wipe the local cache (and fail any unsent messages). | +| `getCacheSize()` | `Int64` | Current on-disk cache size in bytes. Reads the disk; call off the main thread. | +| `downloadMedia(url:messageServerId:onSuccess:onError:)` | `Void` | Download and cache an attachment's bytes. | +| `downloadMedia(url:messageServerId:onProgress:onSuccess:onError:)` | `Void` | Same, with a `0...100` progress closure. | +| `getCachedMediaPath(_ url: String)` | `String?` | Local path to already-cached bytes, or `nil`. | +| `getExistingUploadFileRequest(receiverId:receiverType:parentMessageId:)` | `UploadFileRequest?` | Re-attach to a kept offline upload batch; `nil` when offline is off or none exists. | + +On the returned `UploadFileRequest`, `getItems()` lists each file's `UploadFileItemStatus`, and `retryAttachment(fileId:)` retries one failed file. See [Upload Files](/sdk/ios/upload-files). + +### Listener registration + +Listeners are held **weakly** — keep a strong reference — and each ID holds one listener, so use a distinct ID per listener. + +| Method | Listener | +|---|---| +| `addMessageListener(_:_:)` / `removeMessageListener(_:)` | `CometChatMessageDelegate` (adds `onMessagesSynced`) | +| `addOutboxListener(_:_:)` / `removeOutboxListener(_:)` | `OutboxListener` | +| `addSyncListener(_:_:)` / `removeSyncListener(_:)` | `SyncListener` | +| `addOfflineCacheResetListener(_:_:)` / `removeOfflineCacheResetListener(_:)` | `OfflineCacheResetListener` | +| `addMediaDownloadListener(_:_:)` / `removeMediaDownloadListener(_:)` | `MediaDownloadListener` | + +## Listeners and callbacks + +### Two-phase fetch overloads + +iOS expresses the two-phase read as `onCached…` / `onServer…` / `onError` closure overloads of the existing fetch methods. The only listener protocols on the read path are `ConversationDetailsListener` and `MessageDetailsListener`. With offline off the cached closure never fires. + +| Call | Callbacks | +|---|---| +| `MessagesRequest.fetchNext` / `fetchPrevious` | `onCachedMessages([BaseMessage])`, `onServerMessages([BaseMessage])`, `onError(CometChatException?)` | +| `ThreadsRequest.fetchNext` | `onCachedThreads([MessageThread])`, `onServerThreads([MessageThread])`, `onError(CometChatException?)` | +| `CometChat.getMessageDetails(_:listener:)` | `MessageDetailsListener`: `onCachedMessage(BaseMessage)`, `onServerMessage(BaseMessage?)`, `onError(CometChatException?)` | +| `ConversationRequest.fetchNext` | `onCachedConversations([Conversation])`, `onServerConversations([Conversation])`, `onError(CometChatException?)` | +| `CometChat.getConversation(conversationWith:conversationType:listener:)` | `ConversationDetailsListener`: `onCachedConversation(Conversation)`, `onServerConversation(Conversation?)`, `onError(CometChatException?)` | +| `UsersRequest.fetchNext`, `BlockedUserRequest.fetchNext` | `onCachedUsers([User])`, `onServerUsers([User])`, `onError(CometChatException?)` | +| `GroupsRequest.fetchNext` | `onCachedGroups([Group])`, `onServerGroups([Group])`, `onError(CometChatException?)` | +| `GroupMembersRequest.fetchNext` | `onCachedGroupMembers([GroupMember])`, `onServerGroupMembers([GroupMember])`, `onError(CometChatException?)` | +| `BannedGroupMembersRequest.fetchNext` | `onCachedBannedGroupMembers([GroupMember])`, `onServerBannedGroupMembers([GroupMember])`, `onError(CometChatException?)` | +| `ReactionsRequest.fetchNext` / `fetchPrevious` | `onCachedReactions([Reaction])`, `onServerReactions([Reaction])`, `onError(CometChatException?)` | +| `NotificationFeedRequest.fetchNext` | `onCachedNotificationFeedItems([NotificationFeedItem])`, `onServerNotificationFeedItems([NotificationFeedItem])`, `onError(CometChatException?)` | + +### Send, outbox, sync, cache, and media callbacks + +| Surface | Callbacks | +|---|---| +| Send closures (`sendTextMessage` / `sendMediaMessage` / `sendCustomMessage` / `sendInteractiveMessage`) | `onMessagePending(message)`, `onMessageSent(message)`, `onError(CometChatException?)` | +| `OutboxListener` | `onOutboxMessagePending(_ message: BaseMessage)`, `onOutboxMessageSent(_ message: BaseMessage)`, `onOutboxMessageFailed(_ message: BaseMessage, _ reason: CometChat.MessageFailureReason)` | +| `SyncListener` | `onConversationSyncStarted()`, `onConversationSyncCompleted(_ conversations: [Conversation])`, `onConversationSyncError(_ error: CometChatException)`, `onMessageSyncStarted()`, `onMessageSyncCompleted(_ messages: [BaseMessage], _ outcome: CometChat.SyncOutcome)`, `onMessageSyncError(_ error: CometChatException)` | +| `OfflineCacheResetListener` | `onOfflineCacheReset(_ reason: CometChat.OfflineCacheResetReason, _ pendingMessagesLost: Int)`; `pendingMessagesLost` is `CometChat.offlinePendingCountUnknown` (`-1`) when the number is unknown | +| `downloadMedia` closures | `onProgress(Int)`, `onSuccess(String)`, `onError(CometChatException)` | +| `MediaDownloadListener` | `onMediaDownloaded(_ messageId: String, _ url: String, _ localPath: String)`, `onMediaDownloadFailed(_ attachment: Attachment?, _ error: CometChatException)` | +| `CometChatMessageDelegate` | `onMessagesSynced(_ messages: [BaseMessage])` (offline addition to the existing delegate) | + +All listener methods are optional (`@objc optional`). + +## What the cache can answer + +The cached phase answers a read only if **every** active filter is locally computable; one server-only filter returns an empty cached page (the server phase still answers in full). Enrichment flags never block the cached phase. + +| Read | Answered from cache | Server-only (empty cached page) | Enrichment (never blocks) | +|---|---|---|---| +| `MessagesRequest` | category/categories, type/types, parent message (threads), hide replies, hide deleted, tags, has mentions, mentioned UIDs, has attachments, attachment types, hide messages from blocked users, has reactions, unread, pinned, saved | `searchKeyword`, `has(links:)`, `updatesOnly(onlyUpdates:)`, `setInteractionGoalCompletedOnly(_:)`; a group read narrowed to one sender | `withTags`, `withParent`, `mentionsWithTagInfo`, `mentionsWithBlockedInfo`, `hideQuotedMessages` | +| `ConversationRequest` | conversation type, unread, tags, user tags, group tags, hide/only agentic, include blocked users, with blocked info, pinned by | `searchKeyword` | `withTags`, `withUserAndGroupTags` | +| Users, groups, group members, banned members, blocked users, reactions, notification feed | Replays the snapshot recorded online for the **same builder settings**, page by page | `searchKeyword`; presence filters (`UsersRequest.set(status:)`, `GroupMembersRequest.set(status:)`) | — | + +## Enums + +The enums are available as `CometChat.` in Swift (for example `CometChat.MessageStatus`). In Objective-C they are named `CometChat` (for example `CometChatMessageStatus`). + +| Enum | Values | +|---|---| +| `CometChat.MediaCachingMode` | `.auto` (auto-download + retain received media), `.off` (skip auto-download; metadata still cached) | +| `CometChat.PendingMessageOverflowPolicy` | `.dropOldest` (evict the oldest pending to admit a newer send), `.rejectNewest` (reject the new send) | +| `CometChat.EncryptionFailureBehavior` | `.onlineOnly` (server-only; never plaintext), `.plaintextFallback` (open an unencrypted cache) | +| `CometChat.MessageStatus` | `.pending`, `.sending`, `.sent`, `.delivered`, `.read`, `.failed` | +| `CometChat.MessageSource` | `.realtime` (arrived over the socket), `.sync` (arrived via the reconnect sweep) | +| `CometChat.MessageFailureReason` | `.transient` (retry likely helps), `.permanent` (retry won't help), `.queueOverflow` (evicted to admit a newer send), `.queueFull` (new send rejected, queue full), `.byteOverflow` (media exceeded the byte budget), `.discarded` (removed before it could send) | +| `CometChat.SyncOutcome` | `.caughtUp` (gap replayed exactly), `.partial` (a page was ingested but more remains) | +| `CometChat.SyncState` | `.idle`, `.syncing` | +| `CometChat.OfflineCacheResetReason` | `.keyLost`, `.wrongKey`, `.corrupted`, `.escalated` (unclassified failure recurred and was escalated), `.encryptionChanged` (the `encryption` setting changed between launches) | +| `UploadFileState` | `.queued`, `.uploading`, `.uploaded`, `.failed`, `.rejected` | + +## Model fields + +| Field | Type | Meaning | +|---|---|---| +| `BaseMessage.localMessageId` | `String?` | SDK-generated local handle for an optimistic/offline send; the stable key to reconcile the row when the server ack arrives. Read-only. `nil` for messages that never went through the outbox. | +| `BaseMessage.messageStatus` | `CometChat.MessageStatus` | Lifecycle status of the message. Not the same as the legacy `BaseMessage.status` string. | +| `BaseMessage.failureReason` / `getFailureReason()` | `CometChat.MessageFailureReason?` | Why a failed message last failed; `nil` otherwise. Device-only, never sent to the server. | +| `BaseMessage.source` | `CometChat.MessageSource` | How a message reached the app. Read-only. | +| `Attachment.localPath` | `String?` | Absolute device path to cached media bytes, or `nil` when not cached. Never sent to the server. | +| `UploadFileItemStatus` (from `UploadFileRequest.getItems()`) | — | `fileId`, `file` (`nil` once uploaded; no bytes after a restart), `name`, `size`, `mime`, `state` (`UploadFileState`), `error`, `attachment`, `localCopyPath`. | + +## Error codes + +Compare `CometChatException.errorCode` against these `CometChatConstants.Errors` constants. + +| Code | Raised when | +|---|---| +| `ERROR_INTERNET_UNAVAILABLE` | A read reached the server phase with no connection (fires after the cached page on a two-phase fetch), or a send was queued offline (the "queued, not failed" notice). | +| `ERROR_REQUEST_IN_PROGRESS` | A two-phase fetch on a `MessagesRequest`, `ThreadsRequest` or `ReactionsRequest` was called while its previous fetch is still in flight. User, group, member and banned-member lists return an empty server result instead. | +| `ERROR_USER_NOT_LOGGED_IN` | `forceSyncNow(onSuccess:onError:)` was called with nobody logged in. | +| `ERROR_OFFLINE_SYNC_IN_PROGRESS` | `forceSyncNow(onSuccess:onError:)` was called while a sync is already running. | +| `ERROR_OFFLINE_SYNC_COOLDOWN` | `forceSyncNow(onSuccess:onError:)` was called within the throttle cooldown. | +| `ERROR_OFFLINE_DISABLED` | An offline-only operation was called while offline support is not active. | +| `ERROR_OFFLINE_MESSAGE_DISCARDED` | A queued message was removed before it could send. | +| `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` or `discardLocalMessage` changed nothing. Try again. | +| `ERROR_QUEUE_FULL` | A new send was refused because the outbox is full (`.rejectNewest`). | +| `ERROR_QUEUE_OVERFLOW` | A queued message was evicted to admit a newer send (`.dropOldest`). | +| `ERROR_OFFLINE_MEDIA_BYTE_OVERFLOW` | Outgoing media would exceed `maxMediaCacheSize`. | +| `ERROR_OFFLINE_MEDIA_COPY_FAILED` | An outgoing media file could not be copied for offline send. | +| `ERROR_OFFLINE_MEDIA_NOT_CACHED` | `downloadMedia`: no cached bytes and the device is offline. | +| `ERROR_OFFLINE_MEDIA_UNRESOLVABLE` | `downloadMedia`: the cache missed, the download failed and re-signing produced nothing usable. | + +A background download that fails reports `onMediaDownloadFailed` with the error code string `"ERROR_MEDIA_DOWNLOAD_FAILED"`, which is not a `CometChatConstants.Errors` constant. + +## Legacy overloads + +The classic single-callback overloads are **not** deprecated on iOS. They are unchanged and never deliver a cached or pending result. The two-phase and pending-aware overloads are additive, and you choose between them by argument label. Only these are deprecated: + +| Deprecated | Replacement | +|---|---| +| `getPendingMessages() -> [BaseMessage]` | `getPendingMessages(onSuccess:onError:)` | +| `NotificationFeedRequest.fetchNext(onCachedFeedItems:onServerFeedItems:onError:)` | `fetchNext(onCachedNotificationFeedItems:onServerNotificationFeedItems:onError:)` | + +| Classic call (unchanged) | Offline-aware overload | +|---|---| +| `MessagesRequest.fetchNext(onSuccess:onError:)` | `fetchNext(onCachedMessages:onServerMessages:onError:)` | +| `MessagesRequest.fetchPrevious(onSuccess:onError:)` | `fetchPrevious(onCachedMessages:onServerMessages:onError:)` | +| `ConversationRequest.fetchNext(onSuccess:onError:)` | `fetchNext(onCachedConversations:onServerConversations:onError:)` | +| `ThreadsRequest.fetchNext(onSuccess:onError:)` | `fetchNext(onCachedThreads:onServerThreads:onError:)` | +| `CometChat.getMessageDetails(_:onSuccess:onError:)` | `getMessageDetails(_:listener:)` with a `MessageDetailsListener` | +| `CometChat.sendTextMessage(message:onSuccess:onError:)` | `sendTextMessage(message:onMessagePending:onMessageSent:onError:)` | +| `CometChat.sendMediaMessage(message:onSuccess:onError:)` | `sendMediaMessage(message:onMessagePending:onMessageSent:onError:)` | +| `CometChat.sendCustomMessage(message:onSuccess:onError:)` | `sendCustomMessage(message:onMessagePending:onMessageSent:onError:)` | +| `CometChat.sendInteractiveMessage(message:onSuccess:onError:)` | `sendInteractiveMessage(message:onMessagePending:onMessageSent:onError:)` | + +The entity-list requests (`UsersRequest`, `GroupsRequest`, `GroupMembersRequest`, `BannedGroupMembersRequest`, `BlockedUserRequest`, `ReactionsRequest`, `NotificationFeedRequest`) follow the same pattern: keep `fetchNext(onSuccess:onError:)` or add the `onCached…` / `onServer…` overload listed above. diff --git a/sdk/ios/offline-send.mdx b/sdk/ios/offline-send.mdx new file mode 100644 index 000000000..1cb006465 --- /dev/null +++ b/sdk/ios/offline-send.mdx @@ -0,0 +1,271 @@ +--- +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 iOS SDK." +--- + +{/* TL;DR for Agents and Quick Reference */} + + +```swift +// Send with the offline-aware overload (queued when offline, auto-sent on reconnect) +CometChat.sendTextMessage(message: textMessage, onMessagePending: { message in + // messageStatus == .pending, fires right after the message is durably queued +}, onMessageSent: { message in + // server ack: real id + sentAt +}, onError: { error in + // ERROR_INTERNET_UNAVAILABLE = queued, not failed. Any other code = failed. +}) + +// Read offline state off a message +let localId = message.localMessageId // String?, stable client handle +let status = message.messageStatus // .pending .sending .sent .delivered .read .failed +let reason = message.getFailureReason() // CometChat.MessageFailureReason?, set only while failed + +// Inspect and recover the outbox +CometChat.getPendingMessages(onSuccess: { messages in }, onError: { error in }) // pending + sending + failed +CometChat.retryMessage(localId, onSuccess: { }, onError: { error in }) // one message +CometChat.retryAllFailedMessages() // transient failures only +CometChat.discardLocalMessage(localId, onSuccess: { }, onError: { error in }) // delete an unsent message + +// Observe every outbox transition, app-wide +CometChat.addOutboxListener("OUTBOX_LISTENER", outboxListener) // OutboxListener +``` + + + +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 overloads, 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/ios/offline-enable); with offline off, a send over no connection fails exactly as before. + + +The classic `sendTextMessage(message:onSuccess:onError:)` and its media, custom and interactive siblings are unchanged. They never queue and never report a pending stage; to get the offline behaviour, call the overloads below. + +## Send a Message + +Call the overload that takes `onMessagePending`, `onMessageSent` and `onError`. 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 (code `ERROR_OFFLINE_MEDIA_COPY_FAILED`, reason `.permanent`), only `onError` fires. + +```swift +let textMessage = TextMessage(receiverUid: "UID", text: "Hello!", receiverType: .user) + +CometChat.sendTextMessage(message: textMessage, onMessagePending: { message in + // Queued and persisted; message.messageStatus == .pending + print("Pending, local id: \(message.localMessageId ?? "")") +}, onMessageSent: { message in + // Server ack: message now carries its real id and sentAt + print("Sent: \(message.id)") +}, onError: { error in + // Tell "queued, not failed" from a real failure by the error code + print("Send error: \(error?.errorCode ?? "")") +}) +``` + +The same three closures are accepted by all four persistent send methods: + +```swift +CometChat.sendTextMessage(message: textMessage, onMessagePending: { _ in }, onMessageSent: { _ in }, onError: { _ in }) +CometChat.sendMediaMessage(message: mediaMessage, onMessagePending: { _ in }, onMessageSent: { _ in }, onError: { _ in }) +CometChat.sendCustomMessage(message: customMessage, onMessagePending: { _ in }, onMessageSent: { _ in }, onError: { _ in }) +CometChat.sendInteractiveMessage(message: interactiveMessage, onMessagePending: { _ in }, onMessageSent: { _ in }, onError: { _ in }) +``` + +Each closure receives the concrete message type you sent (`TextMessage`, `MediaMessage`, and so on). An invalid send — empty text, or a custom message with no `customData` — is rejected with `onError` before anything is stored: it fires no `onMessagePending` and never enters the outbox. + +All callbacks are delivered on the main thread. + +## Queued, Not Failed + +When the device is offline, the send still fires `onMessagePending`, then fires `onError` **once** with the code `ERROR_INTERNET_UNAVAILABLE`. This is a **notification, not a failure**: the message stays durably pending, and `onMessageSent` fires **later**, on reconnect, when it actually goes. This is the default behaviour, with `autoSendPendingOnReconnect` on. With it off, an offline send becomes failed (`.transient`) right away and `onError` carries the same code, so read the outcome from the `OutboxListener` or from `getPendingMessages`, and 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. Read the error code to tell the two apart: + +| `error.errorCode` | Meaning | What to do | +|---|---|---| +| `ERROR_INTERNET_UNAVAILABLE` | Queued; not sent yet. Auto-sends on reconnect (with `autoSendPendingOnReconnect` on, the default). | Show as sending/queued; nothing to do. | +| Anything else | A real failure; the message will not retry on its own. | Offer the user a manual retry (see below). | + +```swift +onError: { error in + if error?.errorCode == CometChatConstants.Errors.ERROR_INTERNET_UNAVAILABLE { + // Queued offline — it will send automatically on reconnect + } else { + // Real failure — surface a retry action + } +} +``` + + +The closures you pass to a send live in memory. If the app is killed while a message is queued, the send still completes on the next launch, but your closures are gone. Use the app-wide [`OutboxListener`](#observe-the-queue-app-wide) to hear about every outcome, including those. + + +### Message status and failure reason + +Every message exposes its offline state. `localMessageId` is the SDK-generated handle stamped at compose time. It stays stable across the whole lifecycle — use it to retry or discard the message. + +| Property | Type | Notes | +|---|---|---| +| `localMessageId` | `String?` | Client handle; read-only. `nil` for messages that never went through the outbox. | +| `messageStatus` | `CometChat.MessageStatus` | `.pending`, `.sending`, `.sent`, `.delivered`, `.read`, `.failed`. | +| `getFailureReason()` / `failureReason` | `CometChat.MessageFailureReason?` | Set only on a failed message the SDK hands you; otherwise `nil`. | +| `source` | `CometChat.MessageSource` | `.realtime` or `.sync`. How a received message reached the app. | + + +Use `messageStatus`, not `status`. `BaseMessage.status` is a pre-existing `String` property and is not the offline status. + + +`MessageFailureReason` values: + +| Reason | When | +|---|---| +| `.transient` | A temporary server error (a completed 5xx response or 429), no answer after 5 automatic reconnect attempts, 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. | +| `.queueFull` | A new send was refused because the queue is full (count cap). | +| `.queueOverflow` | An older queued message was evicted to admit a newer one (`.dropOldest` only). | +| `.byteOverflow` | A media send was refused because caching its bytes would exceed `maxMediaCacheSize`, even after the SDK evicted cached received media to make room. | +| `.discarded` | A queued message was removed before it could send: by `clearOfflineCache`, or because its conversation was removed. Reported with code `ERROR_OFFLINE_MESSAGE_DISCARDED`. (`discardLocalMessage` reports `onSuccess` instead.) | + +## Inspect the Queue + +`getPendingMessages(onSuccess:onError:)` returns the **entire unsent outbox** — every pending, sending and failed message, ordered by local compose time. Sent messages are not included. Read each message's `messageStatus` to group them. + +```swift +CometChat.getPendingMessages(onSuccess: { messages in + for m in messages { + print("\(m.localMessageId ?? "") -> \(m.messageStatus)") + } +}, onError: { error in + print("Could not read the outbox: \(error.errorCode)") +}) +``` + +If the cache is momentarily unavailable (busy, locked or full), it calls `onError` with `ERROR_OFFLINE_MESSAGE_PERSIST_FAILED` rather than answering an empty list — try again. + + +The synchronous `getPendingMessages() -> [BaseMessage]` still exists but is deprecated: it cannot tell an empty outbox from one that is momentarily unreadable. + + +### Observe the queue app-wide + +The per-call closures live only as long as the app does. 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 `OutboxListener`. Its failed callback carries the `MessageFailureReason`. + +```swift +final class OutboxObserver: NSObject, OutboxListener { + + func onOutboxMessagePending(_ message: BaseMessage) { } + + func onOutboxMessageSent(_ message: BaseMessage) { } + + func onOutboxMessageFailed(_ message: BaseMessage, _ reason: CometChat.MessageFailureReason) { + print("Failed: \(message.localMessageId ?? "") (\(reason))") + } +} + +// Keep a strong reference — the SDK holds listeners weakly. +let outboxObserver = OutboxObserver() +CometChat.addOutboxListener("OUTBOX_LISTENER", outboxObserver) + +// Remove it when no longer needed +CometChat.removeOutboxListener("OUTBOX_LISTENER") +``` + + +The SDK holds listeners **weakly**, so retain the object yourself. Registering a second listener under the same ID replaces the first, and removing an ID removes whatever is registered under it — use a distinct ID for each listener. + + +## 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 `localMessageId`. + +- **`retryMessage(_:onSuccess:onError:)`** 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. `onSuccess` fires when the message is sent; `onError` receives a rejection or that attempt's error. +- **`retryAllFailedMessages()`** retries **transient** failures only; permanent ones are skipped. It has no callback — watch the outcome on your `OutboxListener`. +- **`discardLocalMessage(_:onSuccess:onError:)`** deletes an unsent (pending or failed) message from the device, including its copied media. The original send closures get no final outcome, and no outbox event fires. + +```swift +CometChat.retryMessage(localMessageId, onSuccess: { + print("Sent") +}, onError: { error in + print("Retry rejected or failed: \(error.errorCode)") +}) + +CometChat.retryAllFailedMessages() + +CometChat.discardLocalMessage(localMessageId, onSuccess: { + print("Discarded") +}, onError: { error in + print("Discard rejected: \(error.errorCode)") +}) +``` + +There is also a fire-and-forget `retryMessage(_ message: BaseMessage)` that takes the message itself and reports nothing. The callback forms of `retryMessage` and `discardLocalMessage` are Swift-only. + +`retryMessage` and `discardLocalMessage` reject, changing nothing, when: + +| Error code | Reason | +|---|---| +| `ERROR_OFFLINE_MESSAGE_IN_FLIGHT` | The message is already on the wire. `retryMessage` rejects `.sending` messages; `discardLocalMessage` also rejects a pending message whose request is still outstanding. | +| `ERROR_OFFLINE_MESSAGE_NOT_FOUND` | No unsent message has that id (unknown, empty, or already sent). | +| `ERROR_OFFLINE_DISABLED` | Offline support is not running. | +| `ERROR_OFFLINE_MESSAGE_PERSIST_FAILED` | The local cache was briefly unavailable. Try again. | + +## Auto-send on Reconnect + +By default, queued pending messages are sent automatically, in the order they were composed, when the realtime connection is re-established. This is controlled by `OfflineConfig.autoSendPendingOnReconnect` (default `true`); see [Enable Offline Support](/sdk/ios/offline-enable) and the [reference](/sdk/ios/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/ios/offline-sync). + +## Queue Limits + +The outbox holds at most `maxPendingMessages` unsent messages (pending and sending); failed messages do not count. When the queue is full, `OfflineConfig.pendingQueueOverflowPolicy` decides what happens: + +| `PendingMessageOverflowPolicy` | Behaviour on overflow | +|---|---| +| `.rejectNewest` (**default**) | The new send is refused and reported with reason `.queueFull`. Nothing already queued is lost. | +| `.dropOldest` | The oldest pending message is evicted to admit the new one, and reported with `onError` (code `ERROR_QUEUE_OVERFLOW`, reason `.queueOverflow`). If there is no pending message to evict, the new send is refused with `.queueFull`. | + +A media send whose bytes would push the media cache over `maxMediaCacheSize` is refused with reason `.byteOverflow`. The SDK first evicts cached received media, least recently used first, and refuses the send only if that cannot make it fit. + +A **refused** send still reaches a final outcome. It fires `onMessagePending`, then `onError` with the code `ERROR_QUEUE_FULL` (or `ERROR_OFFLINE_MEDIA_BYTE_OVERFLOW`), and the `OutboxListener` receives `onOutboxMessagePending` followed by `onOutboxMessageFailed(_:_:)` carrying the exact reason. On the message you sent, `messageStatus` is `.failed`. An **evicted** message reports `onError` with `ERROR_QUEUE_OVERFLOW` on its own original send closures. + +| Code | Raised when | +|---|---| +| `ERROR_QUEUE_FULL` | A new send was refused because the outbox is full (`.rejectNewest`). | +| `ERROR_QUEUE_OVERFLOW` | A queued message was evicted to make room (`.dropOldest`). | +| `ERROR_OFFLINE_MEDIA_BYTE_OVERFLOW` | Outgoing media would exceed `maxMediaCacheSize`. | +| `ERROR_OFFLINE_MEDIA_COPY_FAILED` | The media file could not be copied for offline send. The send fails permanently and immediately, with no pending stage. | + + +A refused or evicted message has **no durable row** — it does not appear in `getPendingMessages`, and calling `retryMessage` on its id returns `ERROR_OFFLINE_MESSAGE_NOT_FOUND`. Capture these from the failure callback and handle them at that moment. Prefer the default `.rejectNewest`, 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 report an error if the request gets no answer. Their effect reaches the cache only once the server accepts them. + +## Durable Media Uploads + +Send a `MediaMessage` built with `files` and the SDK copies the file bytes to the device before it reports pending. The message then survives an app restart and is sent on reconnect. Only files you pass as `files` are copied. Files you already uploaded need no copy. Uploading new files to hosted storage does need a connection (see [Upload Files](/sdk/ios/upload-files)). + +An in-progress multi-file upload batch survives an app restart, but it never resumes on its own: the SDK keeps the batch on disk and you can re-attach to it. Fetch the existing request for a conversation with `getExistingUploadFileRequest`, and read each file's progress through `getItems()`, which returns a list of `UploadFileItemStatus` (each with a `state` of type `UploadFileState`: `.queued`, `.uploading`, `.uploaded`, `.failed`, or `.rejected`, which cannot be retried). Retry an individual file with `retryAttachment(fileId:)`; the SDK retries from its own copy, so you do not pass the file again. + +```swift +// parentMessageId: 0 for the main composer, the thread parent's id for a thread composer. +if let request = CometChat.getExistingUploadFileRequest(receiverId: "UID", + receiverType: .user, + parentMessageId: 0) { + for item in request.getItems() { + print("\(item.fileId) -> \(item.state)") + if item.state == .failed { + request.retryAttachment(fileId: item.fileId) + } + } +} +``` + +`getExistingUploadFileRequest` returns `nil` when offline support is off, when nobody is logged in, or when no batch was kept for that chat. Files that were still uploading when the app was killed come back as `.failed`. For a restored item, `file` has a name but no bytes, and is `nil` once the file is uploaded — show a preview from `localCopyPath` instead. + +See [Media Caching](/sdk/ios/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/ios/offline-reference). + diff --git a/sdk/ios/offline-sync.mdx b/sdk/ios/offline-sync.mdx new file mode 100644 index 000000000..7560d2150 --- /dev/null +++ b/sdk/ios/offline-sync.mdx @@ -0,0 +1,222 @@ +--- +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 iOS SDK." +--- + +{/* TL;DR for Agents and Quick Reference */} + + +```swift +// Observe the reconnect catch-up sync (conversation phase, then message phase) +final class SyncObserver: NSObject, SyncListener { + func onConversationSyncCompleted(_ conversations: [Conversation]) { /* replace your list */ } + func onMessageSyncCompleted(_ messages: [BaseMessage], _ outcome: CometChat.SyncOutcome) { } + func onMessageSyncError(_ error: CometChatException) { } +} +let syncObserver = SyncObserver() // keep a strong reference +CometChat.addSyncListener("SYNC_LISTENER", syncObserver) +CometChat.removeSyncListener("SYNC_LISTENER") + +// Poll sync state +let state = CometChat.getSyncState() // .idle | .syncing +let lastSync = CometChat.getLastSyncTimestamp() // Int, server epoch seconds, 0 if never + +// Trigger a manual catch-up (with an acceptance callback) +CometChat.forceSyncNow(onSuccess: { /* accepted, started */ }, + onError: { error in /* IN_PROGRESS / COOLDOWN / DISABLED / NOT_LOGGED_IN */ }) + +// Observe cache rebuilds +CometChat.addOfflineCacheResetListener("RESET_LISTENER", resetObserver) // OfflineCacheResetListener + +// Inspect / clear the cache +let bytes = CometChat.getCacheSize() // Int64, call off the main thread +CometChat.clearOfflineCache() // destructive; fails 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/ios/offline-enable). + + +## How catch-up works + +A sync runs automatically when the connection settles after a reconnect. 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. It proceeds in two phases, in order: + +1. **Conversation phase** — the conversation list is fetched as one fresh page. A conversation-list UI can 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 the server no longer returns, along with their cached messages and any still-queued messages for them (which fail with reason `.discarded`). A first-ever sync (cold start) fetches the conversation list only, records where to resume from, and replays no message history. Older history is reached by scrolling online. A cold start fires the conversation-phase callbacks only. + +## 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`, all on the main thread. Every method is optional — implement only the ones you need. + +```swift +final class SyncObserver: NSObject, SyncListener { + + func onConversationSyncStarted() { } + + func onConversationSyncCompleted(_ conversations: [Conversation]) { + // The full, fresh conversation page. Replace your list with it. + } + + func onConversationSyncError(_ error: CometChatException) { } + + func onMessageSyncStarted() { } + + func onMessageSyncCompleted(_ messages: [BaseMessage], _ outcome: CometChat.SyncOutcome) { + // New + changed messages reconciled this sync. Merge by message id. + } + + func onMessageSyncError(_ error: CometChatException) { } +} + +// Keep a strong reference — the SDK holds listeners weakly. +let syncObserver = SyncObserver() +CometChat.addSyncListener("SYNC_LISTENER", syncObserver) +``` + +| Callback | When it fires | +|---|---| +| `onConversationSyncStarted()` | The conversation refresh began. | +| `onConversationSyncCompleted(_ conversations:)` | The refresh succeeded; `conversations` is the full conversation page. | +| `onConversationSyncError(_ error:)` | A transport or parse failure; the cache is left untouched 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 message delegate does not deliver, so you can use it without a `CometChatMessageDelegate`. + +`onMessageSyncCompleted` reports the outcome: + +| `CometChat.SyncOutcome` | Meaning | +|---|---| +| `.caughtUp` | 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")`. + + +Registering a listener under an ID that is already in use replaces the previous listener, and removing an ID removes whatever is registered under it. Use a distinct ID for each listener. + + +## Receiving synced messages + +If you already use a `CometChatMessageDelegate`, 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 the normal received callbacks (`onTextMessageReceived`, `onMediaMessageReceived`, and so on), tagged with `source == .sync`. +- **Updates** to already-cached messages arrive together on `onMessagesSynced(_:)`. + +```swift +extension ViewController: CometChatMessageDelegate { + + func onTextMessageReceived(textMessage: TextMessage) { + if textMessage.source == .sync { + // Delivered by a catch-up sync. Suppress notification spam if you wish. + } + } + + func onMessagesSynced(_ messages: [BaseMessage]) { + // Updates to already-cached messages. Merge by id. + } +} +``` + +Register the delegate as usual, with `CometChat.messagedelegate = self` or `CometChat.addMessageListener("MSG_LISTENER", self)`. The per-message callbacks fire before the bulk `onMessagesSynced`. + +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). A message deleted on the server reaches you as an update on `onMessagesSynced`, not as a removal. + +## Check sync state + +Poll the current sync state and the time of the last completed sync at any time. + +```swift +let state = CometChat.getSyncState() // .idle or .syncing +let lastSync = CometChat.getLastSyncTimestamp() // Int, server epoch seconds; 0 if never synced +``` + +`getSyncState()` returns `.syncing` while a sweep is in flight, otherwise `.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. A manual sync refreshes the cache only; it does not flush the outbox. + +Use the callback overload to learn whether the request was accepted. `onSuccess` fires as soon as the sync is accepted and starts — not when it finishes; the sync's actual progress and outcome still arrive on your registered `SyncListener`. + +```swift +CometChat.forceSyncNow(onSuccess: { + // Accepted and started. +}, onError: { error in + // Rejected — see the code. + print("Sync not started: \(error.errorCode)") +}) +``` + +If you do not need the acceptance result, call the no-argument `CometChat.forceSyncNow()`; a rejected request is silently dropped. + +| Rejection code (`CometChatConstants.Errors`) | Meaning | +|---|---| +| `ERROR_OFFLINE_SYNC_IN_PROGRESS` | A sync (automatic or manual) is already running. | +| `ERROR_OFFLINE_SYNC_COOLDOWN` | A sync ended less than 15 seconds ago; the error description carries the remaining cooldown in milliseconds. | +| `ERROR_OFFLINE_DISABLED` | A user is logged in but offline support is not active. | +| `ERROR_USER_NOT_LOGGED_IN` | Nobody is logged in. | + +## 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 damaged. 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.offlinePendingCountUnknown` (`-1`) when they could not be counted. A logout never fires this listener. + +```swift +final class CacheResetObserver: NSObject, OfflineCacheResetListener { + + func onOfflineCacheReset(_ reason: CometChat.OfflineCacheResetReason, _ pendingMessagesLost: Int) { + // The cache was rebuilt. Re-fetch anything you need; it will re-sync from the server. + if pendingMessagesLost == CometChat.offlinePendingCountUnknown { + print("Cache reset (\(reason)); lost an unknown number of pending messages") + } else { + print("Cache reset (\(reason)); lost \(pendingMessagesLost) pending messages") + } + } +} + +let resetObserver = CacheResetObserver() // keep a strong reference +CometChat.addOfflineCacheResetListener("RESET_LISTENER", resetObserver) +``` + +| `CometChat.OfflineCacheResetReason` | Cause | +|---|---| +| `.keyLost` | The cache's key was gone from the Keychain; a fresh key was minted. | +| `.wrongKey` | The stored key did not open the cache. | +| `.corrupted` | The cache file is damaged. | +| `.escalated` | The cache failed to open on three launches in a row for a reason the SDK could not classify. | +| `.encryptionChanged` | The `encryption` setting changed, so the on-disk cache was rebuilt in the configured format. | + +Remove the listener with `CometChat.removeOfflineCacheResetListener("RESET_LISTENER")`. See [Encrypt the Cache](/sdk/ios/offline-encryption) for the encryption-related reasons. + +## Manage the cache + +Read the current cache size, or clear the cache entirely. + +```swift +DispatchQueue.global().async { + let bytes = CometChat.getCacheSize() // database + media, in bytes + print("Offline cache: \(bytes) bytes") +} + +CometChat.clearOfflineCache() // empty the entire per-user cache +``` + +`getCacheSize()` returns the total on-disk size of the user's cache (the local database files plus the media files) as an `Int64`, or `0` when offline support is disabled or nobody is logged in. It is a **physical** size, so it can read higher than the logical `maxStorageSize` budget. It walks the files on the calling thread, so call it off the main thread. + + +`clearOfflineCache()` is destructive and irreversible. It empties the entire per-user cache — cached messages, conversations, media bytes **and the un-sent outbox**. Each still-queued pending or sending message fails with reason `.discarded` (`ERROR_OFFLINE_MESSAGE_DISCARDED`); messages that had already failed are removed without a further callback. A send already on the wire that the server accepts is still reported sent. The next sync is a cold start. 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 while the same user stays logged in, and is wiped automatically on logout, token expiry, or when a different user logs in. See [Offline Support](/sdk/ios/offline-overview). + diff --git a/sdk/ios/pin-save-message.mdx b/sdk/ios/pin-save-message.mdx index 6724d6c23..46474024f 100644 --- a/sdk/ios/pin-save-message.mdx +++ b/sdk/ios/pin-save-message.mdx @@ -257,6 +257,8 @@ Pin events are broadcast to every participant. Save events are private and reach --- +Your own pin, unpin, save or unsave is reported through its `onSuccess` closure only. It does not fire `onMessagePinned`, `onMessageUnpinned`, `onMessageSaved` or `onMessageUnsaved` on the device that made the change, so update your UI from `onSuccess`. + ## Feature Availability Both features are gated per app and can be checked before showing any UI: diff --git a/sdk/ios/reactions.mdx b/sdk/ios/reactions.mdx index b5897d78f..acdd37def 100644 --- a/sdk/ios/reactions.mdx +++ b/sdk/ios/reactions.mdx @@ -14,7 +14,7 @@ CometChat.addReaction(messageId: 148, reaction: "\u{1F60A}") { msg in } onError: CometChat.removeReaction(messageId: 148, reaction: "\u{1F60A}") { msg in } onError: { err in } // Fetch reactions for a message -let request = ReactionsRequestBuilder().setMessageId(messageId: 148).setLimit(limit: 10).build() +let request = ReactionsRequestBuilder().set(messageId: 148).set(limit: 10).build() request.fetchNext { reactions in } onError: { error in } // Listen for reaction events (CometChatMessageDelegate) @@ -74,9 +74,9 @@ To get all reactions for a specific message, create a `ReactionsRequest` using ` | Method | Description | |--------|-------------| -| `setMessageId(messageId: Int)` | Specifies the message ID (required) | -| `setReaction(reaction: String)` | Filter by specific emoji (optional) | -| `setLimit(limit: Int)` | Number of reactions to fetch (max 100) | +| `set(messageId: Int)` | Specifies the message ID (required) | +| `set(reaction: String)` | Filter by specific emoji (optional) | +| `set(limit: Int)` | Number of reactions to fetch (default 10, maximum 1000) | ### Fetch Next Reactions @@ -84,8 +84,8 @@ To get all reactions for a specific message, create a `ReactionsRequest` using ` ```swift let reactionsRequest = ReactionsRequestBuilder() - .setLimit(limit: 30) - .setMessageId(messageId: 148) + .set(limit: 30) + .set(messageId: 148) .build() reactionsRequest.fetchNext { reactions in @@ -106,9 +106,9 @@ reactionsRequest.fetchNext { reactions in ```swift let reactionsRequest = ReactionsRequestBuilder() - .setLimit(limit: 30) - .setMessageId(messageId: 148) - .setReaction(reaction: "👍") + .set(limit: 30) + .set(messageId: 148) + .set(reaction: "👍") .build() reactionsRequest.fetchNext { reactions in @@ -396,6 +396,12 @@ class ChatViewController: UIViewController, CometChatMessageDelegate { --- +## Offline support + +With offline support enabled (SDK 4.2.0 or later), `fetchNext` and `fetchPrevious` on a `ReactionsRequest` have overloads that deliver `onCachedReactions` first (even when empty) and then `onServerReactions`. Offline, the read ends with `onError`, code `ERROR_INTERNET_UNAVAILABLE`, after the cached page. Each direction keeps its own cached pages. + +The cached phase replays the page this request last received from the server online, for the same builder settings. It does not run those settings as a query against the cache. Adding or removing a reaction needs a connection and is not queued. Real-time reaction events arrive on `CometChatMessageDelegate`, as described above. See [Show Cached Data](/sdk/ios/offline-cached-data). + ## Next Steps diff --git a/sdk/ios/receive-message.mdx b/sdk/ios/receive-message.mdx index 520ca1870..c064f6c5c 100644 --- a/sdk/ios/receive-message.mdx +++ b/sdk/ios/receive-message.mdx @@ -218,6 +218,15 @@ The list of messages received is in the form of [`BaseMessage`](/sdk/reference/m --- +## Offline support + +With offline support enabled (SDK 4.2.0 or later): + +- `fetchPrevious` and `fetchNext` have overloads that answer from the on-device cache first (`onCachedMessages`, even when empty) and then from the server (`onServerMessages`). The cache answers a page only when every filter on the request can be computed on the device. A single server-only filter makes the cached page empty, with no error, while the server phase still answers in full. Offline, the fetch ends with `onError`, code `ERROR_INTERNET_UNAVAILABLE`, after the cached page. +- After a reconnect catch-up, a registered `CometChatMessageDelegate` also receives `onMessagesSynced(_:)`. It carries updates to messages that were already cached, such as reactions, edits, deletes and receipts. Messages that arrived while offline still come one at a time through `onTextMessageReceived`, `onMediaMessageReceived` and the other typed callbacks. + +See [Show Cached Data](/sdk/ios/offline-cached-data) and [Stay in Sync](/sdk/ios/offline-sync). + ## Next Steps diff --git a/sdk/ios/retrieve-conversations.mdx b/sdk/ios/retrieve-conversations.mdx index 5d129cb7d..cf54489e4 100644 --- a/sdk/ios/retrieve-conversations.mdx +++ b/sdk/ios/retrieve-conversations.mdx @@ -249,6 +249,22 @@ CometChat.getConversation(conversationWith: "cometchat-guid-101", conversationTy +### Offline support + +With offline support enabled (SDK 4.2.0 or later), `fetchNext` on a `ConversationRequest` has an overload that delivers a cached page first (`onCachedConversations`, even when empty) and then the server list (`onServerConversations`). Offline, the read ends with `onError`, code `ERROR_INTERNET_UNAVAILABLE`, after the cached page. The cached page is answered only when every builder filter on the request can be computed on the device. A single server-only filter blanks the cached page while the server phase still answers in full. + +| Builder method | Offline | +|---|---| +| `setConversationType(conversationType:)` | Cached | +| `set(unread:)` | Cached | +| `setTags(_:)`, `setUserTags(_:)`, `setGroupTags(_:)` | Cached | +| `hide(agentic:)`, `set(onlyAgentic:)` | Cached | +| `include(blockedUsers:)`, `with(blockedInfo:)` | Cached | +| `set(pinnedBy:)` | Cached | +| `set(searchKeyword:)` | Server only, cached page is empty | + +For a single conversation, use `getConversation(conversationWith:conversationType:listener:)` with a `ConversationDetailsListener`. `onCachedConversation` fires first, and only when the conversation is cached. Offline, the read then ends with `onError`, code `ERROR_INTERNET_UNAVAILABLE`. When nothing is cached, `onError` is the only callback. See [Show Cached Data](/sdk/ios/offline-cached-data). + ## Convert Messages to Conversations Use `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. diff --git a/sdk/ios/retrieve-group-members.mdx b/sdk/ios/retrieve-group-members.mdx index 15ae0fb43..d1f2c7a67 100644 --- a/sdk/ios/retrieve-group-members.mdx +++ b/sdk/ios/retrieve-group-members.mdx @@ -72,6 +72,21 @@ let groupMembersRequest = GroupMembersRequest.GroupMembersRequestBuilder(guid: " +### Set Status + +Filters members by online status: `.online` or `.offline`. If not set, all members are returned. With offline support on, this filter is answered by the server only, so the cached page is empty. + + + +```swift +let groupMembersRequest = GroupMembersRequest.GroupMembersRequestBuilder(guid: "cometchat-guid-1") + .set(limit: 30) + .set(status: .online) + .build() +``` + + + ### Fetch Group Members After configuring the builder, call `build()` to create the request, then `fetchNext()` to retrieve members. Call `fetchNext()` repeatedly on the same instance to paginate. @@ -117,6 +132,19 @@ The `fetchNext()` method returns an array of [`GroupMember`](/sdk/reference/enti --- +## Offline support + +With offline support enabled (SDK 4.2.0 or later), `fetchNext` on a `GroupMembersRequest` has an overload that first delivers `onCachedGroupMembers` from the device, then `onServerGroupMembers` with the live page to reconcile by `uid`. Offline, the read ends with `onError`, code `ERROR_INTERNET_UNAVAILABLE`, after the cached page. A request that uses a server-only filter records nothing, so its cached page is always empty. + +| Builder method | Offline | +|---|---| +| `set(limit:)` | Cached | +| `set(scopes:)` | Cached | +| `set(searchKeyword:)` | Server only | +| `set(status:)` | Server only | + +See [Show Cached Data](/sdk/ios/offline-cached-data). + ## Next Steps diff --git a/sdk/ios/retrieve-groups.mdx b/sdk/ios/retrieve-groups.mdx index a5d9b4e1b..f0d3d324b 100644 --- a/sdk/ios/retrieve-groups.mdx +++ b/sdk/ios/retrieve-groups.mdx @@ -144,6 +144,10 @@ GroupsRequest *groupRequest = [[[GroupsRequestBuilder alloc]initWithLimit:limit] +### Offline support + +With offline support enabled (SDK 4.2.0 or later), `fetchNext` on a `GroupsRequest` has an overload that first delivers `onCachedGroups` from the device, then `onServerGroups` with the live page to reconcile by `guid`. The cached page is the pages the server last returned for this exact request. It is empty when nothing was recorded. Offline, the read ends with `onError`, code `ERROR_INTERNET_UNAVAILABLE`, after the cached page. A request that uses `set(searchKeyword:)` records nothing, so its cached page is always empty. See [Show Cached Data](/sdk/ios/offline-cached-data). + ## Retrieve Particular Group Details Use `getGroup()` to fetch a specific group's details by GUID. diff --git a/sdk/ios/retrieve-users.mdx b/sdk/ios/retrieve-users.mdx index a5b514a53..388a2e0c6 100644 --- a/sdk/ios/retrieve-users.mdx +++ b/sdk/ios/retrieve-users.mdx @@ -281,6 +281,21 @@ UsersRequest *userRequest = [[[UsersRequestBuilder alloc]initWithLimit:limit] bu +### Offline support + +With offline support enabled (SDK 4.2.0 or later), `fetchNext` on a `UsersRequest` has an overload that first delivers `onCachedUsers` from the device, then `onServerUsers` with the live page to reconcile by `uid`. The cached page is the pages the server last returned for this exact request, replayed on the device. It is empty when nothing was recorded. Offline, the read ends with `onError`, code `ERROR_INTERNET_UNAVAILABLE`, after the cached page. + +A request that uses a server-only filter records nothing, so its cached page is always empty. + +| Builder method | Offline | +|---|---| +| `set(searchKeyword:)` | Server only | +| `searchIn(_:)` | Used only with `set(searchKeyword:)`, so server only | +| `set(status:)` | Server only | +| `set(limit:)`, `set(role:)`, `set(roles:)`, `set(tags:)`, `set(UIDs:)`, `hideBlockedUsers(_:)`, `friendsOnly(_:)`, `sortBy(_:)`, `sortOrder(_:)` | Part of the request, so the cached page matches the same settings | + +See [Show Cached Data](/sdk/ios/offline-cached-data). + ## Retrieve Particular User Details Use `getUser()` to fetch a specific user's details by UID. diff --git a/sdk/ios/send-message.mdx b/sdk/ios/send-message.mdx index 7cce7a243..078054e18 100644 --- a/sdk/ios/send-message.mdx +++ b/sdk/ios/send-message.mdx @@ -400,6 +400,27 @@ To handle incoming Card Messages on the client, implement [`onCardMessageReceive --- +## Offline support + +With offline support enabled (SDK 4.2.0 or later), send with the overloads that take `onMessagePending`, `onMessageSent` and `onError`. They exist for text, media, custom and interactive messages. + +```swift +CometChat.sendTextMessage(message: textMessage, onMessagePending: { message in + // Fires on every send while offline support is on. message.messageStatus == .pending +}, onMessageSent: { message in + // The server accepted the message. +}, onError: { error in + // With offline support on, this also fires when the message is queued offline. + // ERROR_INTERNET_UNAVAILABLE means queued, not failed (with autoSendPendingOnReconnect on, the default). + // Any other code is a real failure. + print("Send error: \(error?.errorCode ?? "")") +}) +``` + +Without a connection the message stays pending and is sent when the connection returns. `onError` fires with `ERROR_INTERNET_UNAVAILABLE` as a notice, and `onMessageSent` follows later, so both can fire for one send. Check the error code to tell the two apart. This is the default behaviour, with `autoSendPendingOnReconnect` on. With it off, an offline send fails at once (`.transient`) and you recover it with `retryMessage`. + +Only sends that use the `onMessagePending` overloads are queued. The classic `onSuccess` / `onError` overloads are never queued. See [Send While Offline](/sdk/ios/offline-send). + ## Next Steps diff --git a/sdk/ios/thread-subscription.mdx b/sdk/ios/thread-subscription.mdx index 312a33f67..e25d4312f 100644 --- a/sdk/ios/thread-subscription.mdx +++ b/sdk/ios/thread-subscription.mdx @@ -75,6 +75,7 @@ Because it is a plain `Bool`, read it only off a message you actually fetched: | Situation | `threadSubscribed` | How to treat it | | --- | --- | --- | | Message fetched via `MessagesRequest` | Authoritative | Render the state directly. | +| Message read from the offline cache | Last known value | Render it, then let the server read update it. | | Message that arrived live over the websocket | `false` | Means **"not reported"**, not "unsubscribed". | | Message you constructed locally | `false` | Not yet known. | @@ -162,9 +163,18 @@ The list starts **empty** for every user when the feature launches — it fills +### Offline support + +With offline support enabled (SDK 4.2.0 or later), `fetchNext` on a `ThreadsRequest` has an overload that delivers a cached page first (`onCachedThreads`, even when empty) and then the server list (`onServerThreads`). Offline, it ends with `onError`, code `ERROR_INTERNET_UNAVAILABLE`, after the cached page. + +- With `set(participatedByMe: true)` (the default), `onCachedThreads` holds the threads you take part in that the cache already knows about, in the same order. `set(uid:)` and `set(guid:)` scope that list to one conversation. +- With `set(participatedByMe: false)`, the cached page is always empty. + +See [Show Cached Data](/sdk/ios/offline-cached-data). + ## Keeping Your UI in Sync -The SDK does **not** expose a thread listener or thread-subscription callbacks. It deliberately does not cache subscription state: the state it could not fully observe drifted, so the server's flag on the message is the single source of truth. +The SDK does **not** expose a thread listener or thread-subscription callbacks, and it keeps no subscription state of its own beyond the flag on each message. The server's flag on the message is the single source of truth. With [offline support](/sdk/ios/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. Keep your UI in step yourself: diff --git a/sdk/ios/threaded-messages.mdx b/sdk/ios/threaded-messages.mdx index d49e601d8..ac77336b6 100644 --- a/sdk/ios/threaded-messages.mdx +++ b/sdk/ios/threaded-messages.mdx @@ -174,6 +174,10 @@ messagesRequest.fetchPrevious(onSuccess: { (messages) in --- +## Offline support + +With offline support enabled (SDK 4.2.0 or later), a thread fetch (a `MessagesRequest` with `setParentMessageId(parentMessageId:)`) has an overload that delivers the cached thread page first (`onCachedMessages`, even when empty), then the server copy (`onServerMessages`). Offline, the fetch ends with `onError`, code `ERROR_INTERNET_UNAVAILABLE`, after the cached page. A reply sent from a thread is queued like any other message. See [Show Cached Data](/sdk/ios/offline-cached-data) and [Send While Offline](/sdk/ios/offline-send). + ## Next Steps diff --git a/sdk/ios/upload-files.mdx b/sdk/ios/upload-files.mdx index 7d03569de..ac3d5b1f1 100644 --- a/sdk/ios/upload-files.mdx +++ b/sdk/ios/upload-files.mdx @@ -337,6 +337,10 @@ Every error delivered to `onFileError` / `onFileFailure` is a `CometChatExceptio Server-side rejections at presign time (e.g. policy or plan denials) carry the server's own code and message where one is provided — `ERR_PRESIGNED_URL_FAILED` is the fallback classification. Files removed via `removeAttachment(fileId:)` / `clearAll()` are cancelled **silently** — they leave the batch without an error callback. +## Offline support + +With offline support enabled (SDK 4.2.0 or later), an upload batch and its files are kept on the device. A batch that was in progress survives an app restart, but it never resumes on its own. Fetch it with `CometChat.getExistingUploadFileRequest` and call `retryAttachment(fileId:)` for each file that came back `.failed`. A media message you send without a connection is queued and sent on reconnect. See [Send While Offline](/sdk/ios/offline-send#durable-media-uploads). + ## Next Steps