From 64e40cb93c96f308b17fb89835f161bb63d6184a Mon Sep 17 00:00:00 2001 From: Christoph Aldrian Date: Wed, 28 Oct 2026 14:15:47 +0100 Subject: [PATCH 1/7] docs: add pending messages notes and asset follow-up sketch - WPB-26307 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0126T3TR7JLgpH6bwpF2wAWp --- PENDING_MESSAGES_NOTES.md | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/PENDING_MESSAGES_NOTES.md b/PENDING_MESSAGES_NOTES.md index e94088865d0..d8994e74dc7 100644 --- a/PENDING_MESSAGES_NOTES.md +++ b/PENDING_MESSAGES_NOTES.md @@ -36,3 +36,41 @@ PRD "Pending connection, pending invite, pending message": - **M2 (GA), "Sending of multipart messages when offline" (MEDIUM/LOW):** "As a Wire user with Drive, I want to be able to draft and send a message with attachments, that will be sent after I have internet connection again." It's marked "To be discussed". - **Open questions:** "How to handle multipart messages (drive)". - **No-Drive assets:** not mentioned anywhere. + +## Follow-up PR: assets with Drive off (sketch) + +### Current behaviour (from reading the code, not tested) +1. **Preprocessing** (`AssetsPreprocessor`): local, no network needed. +2. **Upload** (`AssetV3UploadRequestStrategy` via `ZMUpstreamModifiedObjectSync`): + - On `tryAgainLater`, which is what we get offline, the object is retried, not expired. + - It's expired only on `cancelled`/`expired` (`requestExpired`) or on a real error (`shouldRetryToSyncAfterFailed`). + - In the background it uses a background URLSession, whose offline behaviour is unchecked. +3. **Message** (`AssetClientMessageRequestStrategy`): same pattern as text before our change. + - It expires on restart (`!isFresh`) and on any `MessageSender` error. + - Its deadline is checked by the shared `MessageExpirationTimer`, which lives in `ClientMessageRequestStrategy`. + +**Step 0:** manual test with logs. Send an image offline with the flag on and check which of the above actually fails it (log lines with "asset" and "expir"). + +### Sketch +1. **Rule:** move `staysPendingWhileOffline` from `ZMClientMessage` up to `ZMOTRMessage`. +2. **Timer:** + - `timerFired` ignores messages whose deadline has been removed, so the retry loop only needs `removeExpirationDate()` and no timer reference. + - Extend the startup cleanup to assets. +3. **Shared retry loop:** move the loop, including releasing the groups while waiting, from `ClientMessageRequestStrategy` into a small `PendingMessageSending` helper. Both strategies use it, with their own `shouldBeSentPredicate`. +4. **Asset strategy:** skip expiring stale messages for pending messages and use the helper. If the user cancels the upload, the predicate no longer matches and the loop stops. +5. **Upload strategy:** + - For pending messages, `cancelled`/`expired` while offline or in the background re-queue the upload instead of expiring the message. + - Check that interrupted uploads resume after a restart; the encrypted data should still be in the cache. + - Real errors still fail. +6. **Ordering:** the dependency fetch also includes `ZMAssetClientMessage`, so text and images stay in order. Within the 3-minute window that's already the rule today. +7. **UI:** check how an image waiting for the network looks (progress or pending, not failed) and that Cancel still works. Delete while pending stays text-only unless wanted for assets. +8. **Tests:** + - `AssetClientMessageRequestStrategyTests`: the same cases as for text. + - `AssetV3UploadRequestStrategyTests`: a cancel while offline keeps the message pending. + +**Estimate:** about 150–250 lines plus tests. + +**Risks:** +- Background URLSession behaviour. +- Large files: the upload restarts from the beginning. +- Long uploads block later text messages; already the case today within the 3-minute window. From ccdcef55ee4078c3c3d3287b50f418029daf84c4 Mon Sep 17 00:00:00 2001 From: Christoph Aldrian Date: Wed, 28 Oct 2026 14:37:50 +0100 Subject: [PATCH 2/7] docs: update asset follow-up notes with test results - WPB-26307 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0126T3TR7JLgpH6bwpF2wAWp --- PENDING_MESSAGES_NOTES.md | 50 ++++++++++++++------------------------- 1 file changed, 18 insertions(+), 32 deletions(-) diff --git a/PENDING_MESSAGES_NOTES.md b/PENDING_MESSAGES_NOTES.md index d8994e74dc7..0d86605c0e7 100644 --- a/PENDING_MESSAGES_NOTES.md +++ b/PENDING_MESSAGES_NOTES.md @@ -37,40 +37,26 @@ PRD "Pending connection, pending invite, pending message": - **Open questions:** "How to handle multipart messages (drive)". - **No-Drive assets:** not mentioned anywhere. -## Follow-up PR: assets with Drive off (sketch) +## Follow-up: assets with Drive off -### Current behaviour (from reading the code, not tested) -1. **Preprocessing** (`AssetsPreprocessor`): local, no network needed. -2. **Upload** (`AssetV3UploadRequestStrategy` via `ZMUpstreamModifiedObjectSync`): - - On `tryAgainLater`, which is what we get offline, the object is retried, not expired. - - It's expired only on `cancelled`/`expired` (`requestExpired`) or on a real error (`shouldRetryToSyncAfterFailed`). - - In the background it uses a background URLSession, whose offline behaviour is unchecked. -3. **Message** (`AssetClientMessageRequestStrategy`): same pattern as text before our change. - - It expires on restart (`!isFresh`) and on any `MessageSender` error. - - Its deadline is checked by the shared `MessageExpirationTimer`, which lives in `ClientMessageRequestStrategy`. +### Manual test results (2026-10-28, flag on, staging) +1. **Offline:** send an image and wait more than 2 minutes. It does not fail. +2. **Reconnect:** the upload goes out (`POST /v18/assets` → 201), then the asset message is created and sent. +3. **Restart offline:** send an image offline, kill the app, relaunch offline, reconnect. The image is sent automatically. No `expiring stale asset message` in the log. -**Step 0:** manual test with logs. Send an image offline with the flag on and check which of the above actually fails it (log lines with "asset" and "expir"). +### Why it already works +- **Upload** (`AssetV3UploadRequestStrategy` via `ZMUpstreamModifiedObjectSync`): offline requests get `tryAgainLater`, which only retries and never expires. Restarts resume the upload. +- **Message** (`AssetClientMessageRequestStrategy`): the asset message is only created after the upload has succeeded, so after a restart it counts as fresh and the stale-message expiry doesn't run. -### Sketch -1. **Rule:** move `staysPendingWhileOffline` from `ZMClientMessage` up to `ZMOTRMessage`. -2. **Timer:** - - `timerFired` ignores messages whose deadline has been removed, so the retry loop only needs `removeExpirationDate()` and no timer reference. - - Extend the startup cleanup to assets. -3. **Shared retry loop:** move the loop, including releasing the groups while waiting, from `ClientMessageRequestStrategy` into a small `PendingMessageSending` helper. Both strategies use it, with their own `shouldBeSentPredicate`. -4. **Asset strategy:** skip expiring stale messages for pending messages and use the helper. If the user cancels the upload, the predicate no longer matches and the loop stops. -5. **Upload strategy:** - - For pending messages, `cancelled`/`expired` while offline or in the background re-queue the upload instead of expiring the message. - - Check that interrupted uploads resume after a restart; the encrypted data should still be in the cache. - - Real errors still fail. -6. **Ordering:** the dependency fetch also includes `ZMAssetClientMessage`, so text and images stay in order. Within the 3-minute window that's already the rule today. -7. **UI:** check how an image waiting for the network looks (progress or pending, not failed) and that Cancel still works. Delete while pending stays text-only unless wanted for assets. -8. **Tests:** - - `AssetClientMessageRequestStrategyTests`: the same cases as for text. - - `AssetV3UploadRequestStrategyTests`: a cancel while offline keeps the message pending. +### Remaining gaps +1. **Ordering of text and images (probably real, test it):** a text written offline after an image could arrive before the image when more than 3 minutes pass. The image is still uploading, and the dependency fallback fetch only covers `ZMClientMessage`. + - Test: offline, send an image, wait more than 3 minutes, send a text, reconnect, check the order on the receiver. +2. **Failures in the short message step after the upload:** a network drop, a cancelled background task, or the app killed in those few seconds. The image fails as today and can be retried. Rare. +3. **Cosmetic:** `expiration timer fired for message … reaction { emoji: "" }` about 60 seconds after an asset is sent. The asset strategy never stops the shared timer, and `timerFired` ignores sent messages. Most likely on develop too. -**Estimate:** about 150–250 lines plus tests. +### Plan +If gap 1 is confirmed, a small follow-up PR: +- Include `ZMAssetClientMessage` in the dependency fallback fetch. +- Optional, covers gap 2: use the text retry loop in `AssetClientMessageRequestStrategy`. About 30 lines if it calls the existing loop instead of refactoring it. -**Risks:** -- Background URLSession behaviour. -- Large files: the upload restarts from the beginning. -- Long uploads block later text messages; already the case today within the 3-minute window. +No changes are needed to the upload strategy, the timer or the background session. If gap 1 doesn't show up, only document it. From 3036600a37d9226fb84fb8ce6c04fde5c13f44df Mon Sep 17 00:00:00 2001 From: Christoph Aldrian Date: Wed, 28 Oct 2026 16:15:25 +0100 Subject: [PATCH 3/7] feat: keep pending assets in order with later messages - WPB-26307 With offline pending messages, a text written after an image that is still waiting for its upload could be sent first once the image is older than the dependency cap. Also look up the self user's assets that are uploading or waiting to be sent when resolving dependencies. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0126T3TR7JLgpH6bwpF2wAWp --- PENDING_MESSAGES_NOTES.md | 13 +++++--- .../Helpers/ZMMessage+Dependency.swift | 32 +++++++++++++++++-- .../Protocols/OTREntityTests+Dependency.swift | 28 ++++++++++++++++ 3 files changed, 66 insertions(+), 7 deletions(-) diff --git a/PENDING_MESSAGES_NOTES.md b/PENDING_MESSAGES_NOTES.md index 0d86605c0e7..bfddf948efb 100644 --- a/PENDING_MESSAGES_NOTES.md +++ b/PENDING_MESSAGES_NOTES.md @@ -54,9 +54,12 @@ PRD "Pending connection, pending invite, pending message": 2. **Failures in the short message step after the upload:** a network drop, a cancelled background task, or the app killed in those few seconds. The image fails as today and can be retried. Rare. 3. **Cosmetic:** `expiration timer fired for message … reaction { emoji: "" }` about 60 seconds after an asset is sent. The asset strategy never stops the shared timer, and `timerFired` ignores sent messages. Most likely on develop too. -### Plan -If gap 1 is confirmed, a small follow-up PR: -- Include `ZMAssetClientMessage` in the dependency fallback fetch. -- Optional, covers gap 2: use the text retry loop in `AssetClientMessageRequestStrategy`. About 30 lines if it calls the existing loop instead of refactoring it. +### Ordering test result +Gap 1 confirmed: offline, image, more than 3 minutes, then text → the text arrived first. -No changes are needed to the upload strategy, the timer or the background session. If gap 1 doesn't show up, only document it. +### Fix (branch `feat/offline-pending-assets-WPB-26307`) +- The dependency fallback fetch in `ZMMessage+Dependency.swift` also covers the self user's assets that are uploading or uploaded but not yet sent (`version >= 3`, `transferState` uploading/uploaded, not delivered, not expired). It returns whichever pending message is newest. +- Cancelled or failed uploads expire the message, so they can't block forever. +- Test: `OTREntityTests_Dependency`, an uploading asset older than 3 minutes is returned as the dependency of a later text. +- Gap 2 (retry in the short message step after upload) is skipped for now as optional. +- Still to do: run the ordering test in the app again. The image should now arrive first. diff --git a/wire-ios-request-strategy/Sources/Helpers/ZMMessage+Dependency.swift b/wire-ios-request-strategy/Sources/Helpers/ZMMessage+Dependency.swift index ce51fd99069..07ed3a87a99 100644 --- a/wire-ios-request-strategy/Sources/Helpers/ZMMessage+Dependency.swift +++ b/wire-ios-request-strategy/Sources/Helpers/ZMMessage+Dependency.swift @@ -135,12 +135,40 @@ public extension ZMMessage { private func earlierPendingMessageOfSelfUser(in conversation: ZMConversation) -> ZMMessage? { guard let context = managedObjectContext, let serverTimestamp else { return nil } - let request = NSFetchRequest(entityName: ZMClientMessage.entityName()) - request.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [ + let isEarlierPendingMessage = NSCompoundPredicate(andPredicateWithSubpredicates: [ ClientMessageRequestStrategy.shouldBeSentPredicate(context: context), NSPredicate(format: "%K == %@", #keyPath(ZMMessage.visibleInConversation), conversation), NSPredicate(format: "%K < %@", #keyPath(ZMMessage.serverTimestamp), serverTimestamp as NSDate) ]) + // An asset is pending while its upload waits for the network, and only sent once it's uploaded. + let isUploadingOrUploadedAsset = NSPredicate( + format: "version >= 3 AND transferState IN %@", + [AssetTransferState.uploading.rawValue, AssetTransferState.uploaded.rawValue] + ) + + let candidates: [ZMMessage?] = [ + latestMessage(ZMClientMessage.self, matching: isEarlierPendingMessage, in: context), + latestMessage( + ZMAssetClientMessage.self, + matching: NSCompoundPredicate(andPredicateWithSubpredicates: [ + isEarlierPendingMessage, + isUploadingOrUploadedAsset + ]), + in: context + ) + ] + return candidates.compactMap(\.self).max { + ($0.serverTimestamp ?? .distantPast) < ($1.serverTimestamp ?? .distantPast) + } + } + + private func latestMessage( + _ type: Message.Type, + matching predicate: NSPredicate, + in context: NSManagedObjectContext + ) -> Message? { + let request = NSFetchRequest(entityName: type.entityName()) + request.predicate = predicate request.sortDescriptors = [NSSortDescriptor(key: #keyPath(ZMMessage.serverTimestamp), ascending: false)] request.fetchLimit = 1 return context.fetchOrAssert(request: request).first diff --git a/wire-ios-request-strategy/Sources/Protocols/OTREntityTests+Dependency.swift b/wire-ios-request-strategy/Sources/Protocols/OTREntityTests+Dependency.swift index 660158a5759..321ef0083c4 100644 --- a/wire-ios-request-strategy/Sources/Protocols/OTREntityTests+Dependency.swift +++ b/wire-ios-request-strategy/Sources/Protocols/OTREntityTests+Dependency.swift @@ -199,6 +199,34 @@ class OTREntityTests_Dependency: MessagingTestBase { } } + func testThatItReturnsAPreviousUploadingAssetOlderThanThreeMinutesAsDependency_OfflinePendingMessages() { + DeveloperFlag.storage = .temporary() + DeveloperFlag.offlinePendingMessages.enable(true) + defer { DeveloperFlag.storage = .standard } + + let fileURL = FileManager.default.temporaryDirectory.appendingPathComponent("\(UUID()).txt") + try! Data("file".utf8).write(to: fileURL) + defer { try? FileManager.default.removeItem(at: fileURL) } + + syncMOC.performGroupedAndWait { + + // GIVEN + let timeZero = Date(timeIntervalSince1970: 10_000) + let assetMessage = try! self.groupConversation + .appendFile(with: ZMFileMetadata(fileURL: fileURL)) as! ZMAssetClientMessage + assetMessage.serverTimestamp = timeZero + XCTAssertEqual(assetMessage.transferState, .uploading) + + // WHEN + let lastMessage = try! self.groupConversation.appendText(content: "zoo") as! ZMClientMessage + lastMessage.serverTimestamp = timeZero.addingTimeInterval(4 * 60) + + // THEN + let dependency = lastMessage.dependentObjectNeedingUpdateBeforeProcessing + XCTAssertEqual(dependency as? ZMAssetClientMessage, assetMessage) + } + } + func testThatItDoesNotReturnAPreviousSentMessageAsDependency() { syncMOC.performGroupedAndWait { From 59b7cee25771594cd8b4bc2fbf8aa324bd54f131 Mon Sep 17 00:00:00 2001 From: Christoph Aldrian Date: Wed, 28 Oct 2026 16:19:19 +0100 Subject: [PATCH 4/7] Delete PENDING_MESSAGES_NOTES.md --- PENDING_MESSAGES_NOTES.md | 65 --------------------------------------- 1 file changed, 65 deletions(-) delete mode 100644 PENDING_MESSAGES_NOTES.md diff --git a/PENDING_MESSAGES_NOTES.md b/PENDING_MESSAGES_NOTES.md deleted file mode 100644 index bfddf948efb..00000000000 --- a/PENDING_MESSAGES_NOTES.md +++ /dev/null @@ -1,65 +0,0 @@ -# Pending messages (WPB-26307) – notes - -## Which message types stay pending (flag `offlinePendingMessages` on) - -Based on reading the code on this branch; not tested in the app except for text. - -**Stay pending:** everything sent as a `ZMClientMessage` through `ClientMessageRequestStrategy`, except ephemeral messages: -- text, ping, location, reactions, edits, deletes, read and delivery receipts -- Drive messages ("multipart"), once they've been created -- Editing or deleting without the receiver seeing a change only works for plain text. Other types follow the existing rules. - -**Assets with Drive off (`ZMAssetClientMessage`): unchanged** -- They go through `AssetV3UploadRequestStrategy` and `AssetClientMessageRequestStrategy`. -- Offline the upload fails, the message is marked failed and needs Retry. After a restart they still expire. -- They don't block later text messages, because the ordering exception only applies to `ZMClientMessage`. - -**Drive on** -- Files are uploaded as Drive drafts, and sending first publishes them (`publishDraftsUseCase` in `ConversationInputBarViewController+SendButton.swift`). That needs the network. -- Offline, publishing fails and `_sendText` returns before the input bar is cleared. No message is created, and the text and attachments should stay in the input bar. -- The only feedback is a log entry: `Failed to publish drafts`. What the user actually sees hasn't been checked. -- Text-only messages with Drive on take the normal path and stay pending. - -## Manual tests for other types (flag on, offline) - -1. A ping, a location and a reaction stay pending and arrive after reconnecting. -2. An image or file with Drive off fails as before and works with Retry. A text sent after it still arrives. -3. Text only, with Drive on, behaves like text with Drive off. -4. A file with Drive on: nothing is sent and the input bar stays filled. Check how this looks to the user. -5. Online, send a Drive message, then go offline before it's delivered. It stays pending and arrives after reconnecting. - -## What the PRD says about assets -PRD "Pending connection, pending invite, pending message": -- **M0, "Send message in a pending scenario 1":** "send a message while I am in a pending scenario". It doesn't name a message type, so assets are neither included nor excluded. -- **M0 note:** "Messages in pending state should stay floating at the bottom". This is the deferred Part 3. -- **M0, "Editing pending messages":** in scope for M0. -- **M2 (GA), "Sending of multipart messages when offline" (MEDIUM/LOW):** "As a Wire user with Drive, I want to be able to draft and send a message with attachments, that will be sent after I have internet connection again." It's marked "To be discussed". -- **Open questions:** "How to handle multipart messages (drive)". -- **No-Drive assets:** not mentioned anywhere. - -## Follow-up: assets with Drive off - -### Manual test results (2026-10-28, flag on, staging) -1. **Offline:** send an image and wait more than 2 minutes. It does not fail. -2. **Reconnect:** the upload goes out (`POST /v18/assets` → 201), then the asset message is created and sent. -3. **Restart offline:** send an image offline, kill the app, relaunch offline, reconnect. The image is sent automatically. No `expiring stale asset message` in the log. - -### Why it already works -- **Upload** (`AssetV3UploadRequestStrategy` via `ZMUpstreamModifiedObjectSync`): offline requests get `tryAgainLater`, which only retries and never expires. Restarts resume the upload. -- **Message** (`AssetClientMessageRequestStrategy`): the asset message is only created after the upload has succeeded, so after a restart it counts as fresh and the stale-message expiry doesn't run. - -### Remaining gaps -1. **Ordering of text and images (probably real, test it):** a text written offline after an image could arrive before the image when more than 3 minutes pass. The image is still uploading, and the dependency fallback fetch only covers `ZMClientMessage`. - - Test: offline, send an image, wait more than 3 minutes, send a text, reconnect, check the order on the receiver. -2. **Failures in the short message step after the upload:** a network drop, a cancelled background task, or the app killed in those few seconds. The image fails as today and can be retried. Rare. -3. **Cosmetic:** `expiration timer fired for message … reaction { emoji: "" }` about 60 seconds after an asset is sent. The asset strategy never stops the shared timer, and `timerFired` ignores sent messages. Most likely on develop too. - -### Ordering test result -Gap 1 confirmed: offline, image, more than 3 minutes, then text → the text arrived first. - -### Fix (branch `feat/offline-pending-assets-WPB-26307`) -- The dependency fallback fetch in `ZMMessage+Dependency.swift` also covers the self user's assets that are uploading or uploaded but not yet sent (`version >= 3`, `transferState` uploading/uploaded, not delivered, not expired). It returns whichever pending message is newest. -- Cancelled or failed uploads expire the message, so they can't block forever. -- Test: `OTREntityTests_Dependency`, an uploading asset older than 3 minutes is returned as the dependency of a later text. -- Gap 2 (retry in the short message step after upload) is skipped for now as optional. -- Still to do: run the ordering test in the app again. The image should now arrive first. From 648772434b3ec68ea55d5cc74bd871538fa2d83d Mon Sep 17 00:00:00 2001 From: Christoph Aldrian Date: Wed, 28 Oct 2026 16:33:30 +0100 Subject: [PATCH 5/7] docs: add asset PR tests and limitation to notes - WPB-26307 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0126T3TR7JLgpH6bwpF2wAWp --- PENDING_MESSAGES_NOTES.md | 71 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 PENDING_MESSAGES_NOTES.md diff --git a/PENDING_MESSAGES_NOTES.md b/PENDING_MESSAGES_NOTES.md new file mode 100644 index 00000000000..902338a937c --- /dev/null +++ b/PENDING_MESSAGES_NOTES.md @@ -0,0 +1,71 @@ +# Pending messages (WPB-26307) – notes + +## Which message types stay pending (flag `offlinePendingMessages` on) + +Based on reading the code on this branch; not tested in the app except for text. + +**Stay pending:** everything sent as a `ZMClientMessage` through `ClientMessageRequestStrategy`, except ephemeral messages: +- text, ping, location, reactions, edits, deletes, read and delivery receipts +- Drive messages ("multipart"), once they've been created +- Editing or deleting without the receiver seeing a change only works for plain text. Other types follow the existing rules. + +**Assets with Drive off (`ZMAssetClientMessage`): unchanged** +- They go through `AssetV3UploadRequestStrategy` and `AssetClientMessageRequestStrategy`. +- Offline the upload fails, the message is marked failed and needs Retry. After a restart they still expire. +- They don't block later text messages, because the ordering exception only applies to `ZMClientMessage`. + +**Drive on** +- Files are uploaded as Drive drafts, and sending first publishes them (`publishDraftsUseCase` in `ConversationInputBarViewController+SendButton.swift`). That needs the network. +- Offline, publishing fails and `_sendText` returns before the input bar is cleared. No message is created, and the text and attachments should stay in the input bar. +- The only feedback is a log entry: `Failed to publish drafts`. What the user actually sees hasn't been checked. +- Text-only messages with Drive on take the normal path and stay pending. + +## Manual tests for other types (flag on, offline) + +1. A ping, a location and a reaction stay pending and arrive after reconnecting. +2. An image or file with Drive off fails as before and works with Retry. A text sent after it still arrives. +3. Text only, with Drive on, behaves like text with Drive off. +4. A file with Drive on: nothing is sent and the input bar stays filled. Check how this looks to the user. +5. Online, send a Drive message, then go offline before it's delivered. It stays pending and arrives after reconnecting. + +## What the PRD says about assets +PRD "Pending connection, pending invite, pending message": +- **M0, "Send message in a pending scenario 1":** "send a message while I am in a pending scenario". It doesn't name a message type, so assets are neither included nor excluded. +- **M0 note:** "Messages in pending state should stay floating at the bottom". This is the deferred Part 3. +- **M0, "Editing pending messages":** in scope for M0. +- **M2 (GA), "Sending of multipart messages when offline" (MEDIUM/LOW):** "As a Wire user with Drive, I want to be able to draft and send a message with attachments, that will be sent after I have internet connection again." It's marked "To be discussed". +- **Open questions:** "How to handle multipart messages (drive)". +- **No-Drive assets:** not mentioned anywhere. + +## Follow-up: assets with Drive off + +### Manual test results (2026-10-28, flag on, staging) +1. **Offline:** send an image and wait more than 2 minutes. It does not fail. +2. **Reconnect:** the upload goes out (`POST /v18/assets` → 201), then the asset message is created and sent. +3. **Restart offline:** send an image offline, kill the app, relaunch offline, reconnect. The image is sent automatically. No `expiring stale asset message` in the log. + +### Why it already works +- **Upload** (`AssetV3UploadRequestStrategy` via `ZMUpstreamModifiedObjectSync`): offline requests get `tryAgainLater`, which only retries and never expires. Restarts resume the upload. +- **Message** (`AssetClientMessageRequestStrategy`): the asset message is only created after the upload has succeeded, so after a restart it counts as fresh and the stale-message expiry doesn't run. + +### Remaining gaps +1. **Ordering of text and images (probably real, test it):** a text written offline after an image could arrive before the image when more than 3 minutes pass. The image is still uploading, and the dependency fallback fetch only covers `ZMClientMessage`. + - Test: offline, send an image, wait more than 3 minutes, send a text, reconnect, check the order on the receiver. +2. **Failures in the short message step after the upload:** a network drop, a cancelled background task, or the app killed in those few seconds. The image fails as today and can be retried. Rare. +3. **Cosmetic:** `expiration timer fired for message … reaction { emoji: "" }` about 60 seconds after an asset is sent. The asset strategy never stops the shared timer, and `timerFired` ignores sent messages. Most likely on develop too. + +### Ordering test result +Gap 1 confirmed: offline, image, more than 3 minutes, then text → the text arrived first. + +### Fix (branch `feat/offline-pending-assets-WPB-26307`) +- The dependency fallback fetch in `ZMMessage+Dependency.swift` also covers the self user's assets that are uploading or uploaded but not yet sent (`version >= 3`, `transferState` uploading/uploaded, not delivered, not expired). It returns whichever pending message is newest. +- Cancelled or failed uploads expire the message, so they can't block forever. +- Test: `OTREntityTests_Dependency`, an uploading asset older than 3 minutes is returned as the dependency of a later text. +- Gap 2 (retry in the short message step after upload) is skipped for now as optional. +- Still to do: run the ordering test in the app again. The image should now arrive first. +- Known limitation: a pending image can't be cancelled or deleted while it waits for the network. Images show no Cancel button, Delete is hidden for pending messages, and delete-while-pending covers text only. + +### Manual tests for the asset PR +1. Offline: send an image, wait more than 3 minutes, send a text. Reconnect. The receiver gets the image first, then the text. +2. Offline: send an image, kill and relaunch the app offline, reconnect. The image is sent automatically. +3. Offline: send a file (not an image), cancel its upload, then send a text. Reconnect. The text is sent. Skip this if files can't be cancelled either. From e13f7705595f511a00926563451f8a64324e167c Mon Sep 17 00:00:00 2001 From: Christoph Aldrian Date: Wed, 28 Oct 2026 16:39:00 +0100 Subject: [PATCH 6/7] feat: delete pending assets that are still uploading - WPB-26307 An image or file waiting for its upload can't be cancelled, and Delete is hidden for pending messages. With offline pending messages it can wait for a long time, so allow deleting it locally while it's still uploading: the asset message is only sent after the upload, so nobody else has it. The upload is cancelled first, and the deletion runs on the sync context so it can't race with the send. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0126T3TR7JLgpH6bwpF2wAWp --- PENDING_MESSAGES_NOTES.md | 5 +- .../Model/Message/ConversationMessage.swift | 2 +- .../ZMAssetClientMessage+Deletion.swift | 51 ++++++++++++++ .../Messages/ZMAssetClientMessageTests.swift | 68 +++++++++++++++++++ ...ontentViewController+MessageDeletion.swift | 12 ++-- 5 files changed, 129 insertions(+), 9 deletions(-) diff --git a/PENDING_MESSAGES_NOTES.md b/PENDING_MESSAGES_NOTES.md index 902338a937c..bfdb99b5864 100644 --- a/PENDING_MESSAGES_NOTES.md +++ b/PENDING_MESSAGES_NOTES.md @@ -63,9 +63,10 @@ Gap 1 confirmed: offline, image, more than 3 minutes, then text → the text arr - Test: `OTREntityTests_Dependency`, an uploading asset older than 3 minutes is returned as the dependency of a later text. - Gap 2 (retry in the short message step after upload) is skipped for now as optional. - Still to do: run the ordering test in the app again. The image should now arrive first. -- Known limitation: a pending image can't be cancelled or deleted while it waits for the network. Images show no Cancel button, Delete is hidden for pending messages, and delete-while-pending covers text only. +- Delete while uploading: with the flag on, an image or file that is still uploading can be deleted ("Delete for Me"). It's removed locally, since the asset message is only sent after the upload. The delete runs on the sync context, cancels the upload first, and shows "Message Is Being Sent" if the upload has finished in the meantime. ### Manual tests for the asset PR 1. Offline: send an image, wait more than 3 minutes, send a text. Reconnect. The receiver gets the image first, then the text. 2. Offline: send an image, kill and relaunch the app offline, reconnect. The image is sent automatically. -3. Offline: send a file (not an image), cancel its upload, then send a text. Reconnect. The text is sent. Skip this if files can't be cancelled either. +3. Offline: send an image, delete it ("Delete for Me"), then send a text. Reconnect. The receiver gets only the text. +4. Online with a large file (Network Link Conditioner, slow): delete it while uploading. The upload stops and the receiver gets nothing. diff --git a/wire-ios-data-model/Source/Model/Message/ConversationMessage.swift b/wire-ios-data-model/Source/Model/Message/ConversationMessage.swift index 4d66fdaa2f2..2de9d7955b6 100644 --- a/wire-ios-data-model/Source/Model/Message/ConversationMessage.swift +++ b/wire-ios-data-model/Source/Model/Message/ConversationMessage.swift @@ -379,7 +379,7 @@ public extension ZMMessage { } @objc var canBeDeleted: Bool { - deliveryState != .pending || (self as? ZMClientMessage)?.isDeletableWhilePending == true + deliveryState != .pending || (self as? any PendingMessageDeletable)?.isDeletableWhilePending == true } @objc var hasBeenDeleted: Bool { diff --git a/wire-ios-data-model/Source/Model/Message/ZMAssetClientMessage+Deletion.swift b/wire-ios-data-model/Source/Model/Message/ZMAssetClientMessage+Deletion.swift index 3a34c2091af..974c24b1b9b 100644 --- a/wire-ios-data-model/Source/Model/Message/ZMAssetClientMessage+Deletion.swift +++ b/wire-ios-data-model/Source/Model/Message/ZMAssetClientMessage+Deletion.swift @@ -25,6 +25,57 @@ public extension Notification.Name { static let deleteAssetNotification = Notification.Name("deleteAssetNotification") } +/// A pending message that can be deleted locally while nobody else has it yet. +public protocol PendingMessageDeletable: ZMOTRMessage { + + /// Whether the message can be deleted without sending a delete. + var isDeletableWhilePending: Bool { get } + + /// Deletes the message without sending a delete. Call it on the sync context. + /// + /// - Returns: `false` if the message can no longer be deleted this way, e.g. because sending started. + @discardableResult + func deletePendingMessage() -> Bool +} + +extension ZMClientMessage: PendingMessageDeletable {} + +extension ZMAssetClientMessage: PendingMessageDeletable { + + /// Whether the asset can be deleted without sending a delete, because it's still uploading. The asset + /// message is only sent once the upload has succeeded, so nobody else has it. + public var isDeletableWhilePending: Bool { + guard + DeveloperFlag.offlinePendingMessages.isOn, + !isEphemeral, + !isExpired, + !delivered, + transferState == .uploading, + let sender, sender.isSelfUser + else { + return false + } + return true + } + + /// Deletes an asset that is still uploading, without sending a delete. + /// + /// Call it on the sync context, where the asset message is sent once the upload has succeeded, so it + /// can't be deleted while it's being sent. + @discardableResult + public func deletePendingMessage() -> Bool { + guard isDeletableWhilePending, let managedObjectContext else { return false } + + // Cancel and save first, so the upload strategy also cancels a running upload request. + cancelTransfer() + managedObjectContext.saveOrRollback() + + removeClearingSender(true) + managedObjectContext.delete(self) + return true + } +} + extension ZMAssetClientMessage { func deleteContent() { diff --git a/wire-ios-data-model/Tests/Model/Messages/ZMAssetClientMessageTests.swift b/wire-ios-data-model/Tests/Model/Messages/ZMAssetClientMessageTests.swift index 7db36143690..97450bf2c34 100644 --- a/wire-ios-data-model/Tests/Model/Messages/ZMAssetClientMessageTests.swift +++ b/wire-ios-data-model/Tests/Model/Messages/ZMAssetClientMessageTests.swift @@ -1682,3 +1682,71 @@ extension ZMAssetClientMessageTests { } } } + +// MARK: - Pending deletion + +final class ZMAssetClientMessageTests_PendingDeletion: BaseZMAssetClientMessageTests { + + override func setUp() { + super.setUp() + DeveloperFlag.storage = .temporary() + DeveloperFlag.offlinePendingMessages.enable(true) + } + + override func tearDown() { + DeveloperFlag.storage = .standard + super.tearDown() + } + + private func makeUploadingAsset() -> ZMAssetClientMessage { + let conversation = ZMConversation.insertNewObject(in: uiMOC) + conversation.remoteIdentifier = UUID.create() + let message = appendFileMessage(to: conversation)! + XCTAssertEqual(message.transferState, .uploading) + return message + } + + func testThatUploadingAssetCanBeDeleted() { + let message = makeUploadingAsset() + XCTAssertEqual(message.deliveryState, .pending) + XCTAssertTrue(message.isDeletableWhilePending) + XCTAssertTrue(message.canBeDeleted) + } + + func testThatUploadingAssetCanNotBeDeleted_WhenFlagIsDisabled() { + DeveloperFlag.offlinePendingMessages.enable(false) + let message = makeUploadingAsset() + XCTAssertFalse(message.isDeletableWhilePending) + XCTAssertFalse(message.canBeDeleted) + } + + func testThatUploadedAssetCanNotBeDeletedWithoutSendingADelete() { + // GIVEN + let message = makeUploadingAsset() + message.updateTransferState(.uploaded, synchronize: false) + + // THEN + XCTAssertFalse(message.isDeletableWhilePending) + XCTAssertFalse(message.canBeDeleted) + XCTAssertFalse(message.deletePendingMessage()) + XCTAssertFalse(message.isZombieObject) + } + + func testThatItDeletesUploadingAssetWithoutSendingADelete() { + // GIVEN + let message = makeUploadingAsset() + let conversation = message.conversation! + uiMOC.saveOrRollback() + let messageCount = conversation.allMessages.count + + // WHEN + let didDelete = message.deletePendingMessage() + uiMOC.saveOrRollback() + + // THEN + XCTAssertTrue(didDelete) + XCTAssertTrue(message.isZombieObject) + XCTAssertEqual(conversation.allMessages.count, messageCount - 1) + XCTAssertEqual(conversation.hiddenMessages.count, 0) + } +} diff --git a/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/ConversationContentViewController+MessageDeletion.swift b/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/ConversationContentViewController+MessageDeletion.swift index fc900c2567c..97e71600053 100644 --- a/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/ConversationContentViewController+MessageDeletion.swift +++ b/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/ConversationContentViewController+MessageDeletion.swift @@ -37,7 +37,7 @@ private extension ZMConversationMessage { } // A pending message that hasn't been sent yet is only deleted locally, since nobody else has it. - if (self as? ZMClientMessage)?.isDeletableWhilePending == true { + if (self as? any PendingMessageDeletable)?.isDeletableWhilePending == true { return .hide } @@ -72,9 +72,9 @@ final class DeletionDialogPresenter: NSObject { ) -> UIAlertController { let alert = UIAlertController.forMessageDeletion(with: message.deletionConfiguration) { action, _ in - if case .delete = action, let clientMessage = message as? ZMClientMessage, - clientMessage.isDeletableWhilePending { - self.deletePendingMessage(clientMessage, userSession: userSession, completion: completion) + if case .delete = action, let pendingMessage = message as? any PendingMessageDeletable, + pendingMessage.isDeletableWhilePending { + self.deletePendingMessage(pendingMessage, userSession: userSession, completion: completion) return } @@ -113,7 +113,7 @@ final class DeletionDialogPresenter: NSObject { /// Deletes a message that hasn't been sent yet. This happens on the sync context, which also marks the /// message before sending it, so it can't race with a send. private func deletePendingMessage( - _ message: ZMClientMessage, + _ message: any PendingMessageDeletable, userSession: UserSession, completion: @escaping (_ succeeded: Bool, DeletionType?) -> Void ) { @@ -121,7 +121,7 @@ final class DeletionDialogPresenter: NSObject { let syncContext = userSession.contextProvider.syncContext syncContext.perform { - let syncMessage = try? syncContext.existingObject(with: objectID) as? ZMClientMessage + let syncMessage = try? syncContext.existingObject(with: objectID) as? any PendingMessageDeletable let didDelete = syncMessage?.deletePendingMessage() == true if didDelete { syncContext.saveOrRollback() From 50ba88859bcb2881da5dc26cf6a6e888c442db36 Mon Sep 17 00:00:00 2001 From: Christoph Aldrian Date: Thu, 29 Oct 2026 12:11:48 +0100 Subject: [PATCH 7/7] feat: show offline notice in conversation - WPB-25326 While offline (behind the offlinePendingMessages flag), the open conversation shows a notice that messages are sent once online again. It's placed above the messages the self user sent while offline, or below the newest message, and disappears when the device is back online. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0126T3TR7JLgpH6bwpF2wAWp --- ...rsationMessageSectionControllerTests.swift | 44 ++++++++++ .../Generated/Strings+Generated.swift | 6 ++ .../Base.lproj/Localizable.strings | 2 + .../ConversationOfflineNoticeCell.swift | 84 +++++++++++++++++++ ...ConversationMessageSectionController.swift | 22 +++++ ...nContentViewController+OfflineNotice.swift | 40 +++++++++ .../ConversationContentViewController.swift | 3 + .../ConversationTableViewDataSource.swift | 28 ++++++- 8 files changed, 228 insertions(+), 1 deletion(-) create mode 100644 wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/Cells/ConfigurationMessageCell/Components/ConversationOfflineNoticeCell.swift create mode 100644 wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/ConversationContentViewController+OfflineNotice.swift diff --git a/wire-ios/Wire-iOS Tests/ConversationMessageCell/ConversationMessageSectionControllerTests.swift b/wire-ios/Wire-iOS Tests/ConversationMessageCell/ConversationMessageSectionControllerTests.swift index 85dd05b55da..9b46c3a4a42 100644 --- a/wire-ios/Wire-iOS Tests/ConversationMessageCell/ConversationMessageSectionControllerTests.swift +++ b/wire-ios/Wire-iOS Tests/ConversationMessageCell/ConversationMessageSectionControllerTests.swift @@ -137,6 +137,50 @@ final class ConversationMessageSectionControllerTests: XCTestCase { XCTAssertTrue(cellDescriptions[1].instance is ConversationMessageToolboxCellDescription) } + func testCellGrouping_OfflineNoticeAbove() throws { + // GIVEN + let message = MockMessageFactory.textMessage(withText: "Hello") + let context = ConversationMessageContext( + isSameSenderAsPrevious: true, + isTimestampInSameMinuteAsPreviousMessage: true, + offlineNotice: .above + ) + + // WHEN + let section = makeSUT(message: message, context: context) + + // THEN + let cellDescriptions = section.cellDescriptionsForTesting + guard cellDescriptions.count == 4 else { + return XCTFail("Expected 4 cells") + } + + XCTAssertTrue(cellDescriptions[0].instance is ConversationOfflineNoticeCellDescription) + XCTAssertTrue(cellDescriptions[1].instance is ConversationSenderMessageCellDescription) + XCTAssertTrue(cellDescriptions[2].instance is ConversationTextMessageCellDescription) + XCTAssertTrue(cellDescriptions[3].instance is ConversationMessageToolboxCellDescription) + } + + func testCellGrouping_OfflineNoticeBelow() throws { + // GIVEN + let message = MockMessageFactory.textMessage(withText: "Hello") + let context = ConversationMessageContext(isSameSenderAsPrevious: false, offlineNotice: .below) + + // WHEN + let section = makeSUT(message: message, context: context) + + // THEN + let cellDescriptions = section.cellDescriptionsForTesting + guard cellDescriptions.count == 4 else { + return XCTFail("Expected 4 cells") + } + + XCTAssertTrue(cellDescriptions[0].instance is ConversationSenderMessageCellDescription) + XCTAssertTrue(cellDescriptions[1].instance is ConversationTextMessageCellDescription) + XCTAssertTrue(cellDescriptions[2].instance is ConversationMessageToolboxCellDescription) + XCTAssertTrue(cellDescriptions[3].instance is ConversationOfflineNoticeCellDescription) + } + func testCellGrouping_PreviousMessageIsKnock() throws { // Given let message = MockMessageFactory.textMessage(withText: "Hello") diff --git a/wire-ios/Wire-iOS/Generated/Strings+Generated.swift b/wire-ios/Wire-iOS/Generated/Strings+Generated.swift index 3b2cb030125..8fb6a67b252 100644 --- a/wire-ios/Wire-iOS/Generated/Strings+Generated.swift +++ b/wire-ios/Wire-iOS/Generated/Strings+Generated.swift @@ -2586,6 +2586,12 @@ internal enum L10n { } } } + internal enum OfflineNotice { + /// Your messages will be sent once you are online again. + internal static let message = L10n.tr("Localizable", "content.system.offline_notice.message", fallback: "Your messages will be sent once you are online again.") + /// You are offline. + internal static let title = L10n.tr("Localizable", "content.system.offline_notice.title", fallback: "You are offline.") + } internal enum RenamedConv { /// %@ renamed the conversation internal static func title(_ p1: Any) -> String { diff --git a/wire-ios/Wire-iOS/Resources/Localization/Base.lproj/Localizable.strings b/wire-ios/Wire-iOS/Resources/Localization/Base.lproj/Localizable.strings index 5a83e9bd204..4476c751cf6 100644 --- a/wire-ios/Wire-iOS/Resources/Localization/Base.lproj/Localizable.strings +++ b/wire-ios/Wire-iOS/Resources/Localization/Base.lproj/Localizable.strings @@ -675,6 +675,8 @@ "content.system.message_conversation_scheduled_for_deletion.text" = "This group will be automatically deleted on %@, as there are no eligible group admins."; "content.system.message_conversation_scheduled_for_deletion.read_more.text" = "Read more"; +"content.system.offline_notice.title" = "You are offline."; +"content.system.offline_notice.message" = "Your messages will be sent once you are online again."; // "content.system.is_verified" = "All fingerprints are verified"; diff --git a/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/Cells/ConfigurationMessageCell/Components/ConversationOfflineNoticeCell.swift b/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/Cells/ConfigurationMessageCell/Components/ConversationOfflineNoticeCell.swift new file mode 100644 index 00000000000..5d55c263900 --- /dev/null +++ b/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/Cells/ConfigurationMessageCell/Components/ConversationOfflineNoticeCell.swift @@ -0,0 +1,84 @@ +// +// Wire +// Copyright (C) 2026 Wire Swiss GmbH +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see http://www.gnu.org/licenses/. +// + +import UIKit +import WireDataModel +import WireDesign + +/// Tells the user that the device is offline and their messages are sent once it's online again. +final class ConversationOfflineNoticeCell: + ConversationIconBasedCell, + ConversationMessageCell { + + struct Configuration { + let attributedText: NSAttributedString + let icon: UIImage? + let iconTintColor: UIColor + } + + override init(frame: CGRect) { + super.init(frame: frame) + lineView.isHidden = true + } + + @available(*, unavailable) + required init?(coder aDecoder: NSCoder) { + fatalError("init?(coder aDecoder: NSCoder) is not implemented") + } + + func configure(with object: Configuration, animated: Bool) { + attributedText = object.attributedText + imageView.image = object.icon + imageView.tintColor = object.iconTintColor + } +} + +final class ConversationOfflineNoticeCellDescription: ConversationMessageCellDescription { + typealias View = ConversationOfflineNoticeCell + let configuration: View.Configuration + + var message: ZMConversationMessage? + weak var delegate: ConversationMessageCellDelegate? + weak var actionController: ConversationMessageActionController? + + let containsHighlightableContent: Bool = false + + let accessibilityIdentifier: String? = "offline_notice" + let accessibilityLabel: String? + + init() { + typealias Strings = L10n.Localizable.Content.System.OfflineNotice + + let textColor = SemanticColors.Label.textDefault + let attributedText = NSMutableAttributedString( + string: Strings.title, + attributes: [.font: UIFont.systemFont(ofSize: 14, weight: .semibold), .foregroundColor: textColor] + ) + attributedText.append(NSAttributedString( + string: " " + Strings.message, + attributes: [.font: UIFont.systemFont(ofSize: 14, weight: .regular), .foregroundColor: textColor] + )) + + self.configuration = View.Configuration( + attributedText: attributedText, + icon: UIImage(systemName: "wifi.exclamationmark")?.withRenderingMode(.alwaysTemplate), + iconTintColor: ColorTheme.Base.primary(.red) + ) + self.accessibilityLabel = attributedText.string + } +} diff --git a/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/Cells/ConfigurationMessageCell/ConversationMessageSectionController.swift b/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/Cells/ConfigurationMessageCell/ConversationMessageSectionController.swift index 218e0d2a9c4..d407350249e 100644 --- a/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/Cells/ConfigurationMessageCell/ConversationMessageSectionController.swift +++ b/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/Cells/ConfigurationMessageCell/ConversationMessageSectionController.swift @@ -28,6 +28,15 @@ struct ConversationMessageContext: Equatable { var isLastMessage: Bool = false var searchQueries: [String] = [] var previousMessageIsKnock: Bool = false + /// Whether the offline notice is shown with this message, and where. + var offlineNotice: OfflineNoticePosition? +} + +enum OfflineNoticePosition: Equatable { + /// Above the message, which is the first one the self user sent while offline. + case above + /// Below the message, which is the newest one. + case below } protocol ConversationMessageSectionControllerDelegate: AnyObject { @@ -461,6 +470,10 @@ final class ConversationMessageSectionController: NSObject, ZMMessageObserver { cellDescriptions.append(AnyConversationMessageCellDescription(description)) } + if context.offlineNotice == .above { + cellDescriptions.append(AnyConversationMessageCellDescription(ConversationOfflineNoticeCellDescription())) + } + if isBurstTimestampVisible { let description = BurstTimestampSenderMessageCellDescription( message: message, @@ -513,6 +526,10 @@ final class ConversationMessageSectionController: NSObject, ZMMessageObserver { cellDescriptions.append(AnyConversationMessageCellDescription(description)) } + if context.offlineNotice == .below { + cellDescriptions.append(AnyConversationMessageCellDescription(ConversationOfflineNoticeCellDescription())) + } + self.cellDescriptions = cellDescriptions } @@ -591,6 +608,11 @@ final class ConversationMessageSectionController: NSObject, ZMMessageObserver { return true } + // The offline notice is shown before the actual message. + if context.offlineNotice == .above { + return true + } + // This message is from the same sender but in a different minute. if !context.isTimestampInSameMinuteAsPreviousMessage { return true diff --git a/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/ConversationContentViewController+OfflineNotice.swift b/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/ConversationContentViewController+OfflineNotice.swift new file mode 100644 index 00000000000..5b9c9c72911 --- /dev/null +++ b/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/ConversationContentViewController+OfflineNotice.swift @@ -0,0 +1,40 @@ +// +// Wire +// Copyright (C) 2026 Wire Swiss GmbH +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see http://www.gnu.org/licenses/. +// + +import WireSyncEngine + +extension ConversationContentViewController: ZMNetworkAvailabilityObserver { + + /// Shows the offline notice in the messages while the device is offline, like the network status bar. + func observeNetworkAvailabilityForOfflineNotice() { + // The cast is not needed for compilation but is a necessary hack for tests + guard DeveloperFlag.offlinePendingMessages.isOn, let userSession = userSession as? ZMUserSession else { + return + } + + dataSource.showsOfflineNotice = userSession.networkState == .offline + networkAvailabilityObserverToken = ZMNetworkAvailabilityChangeNotification.addNetworkAvailabilityObserver( + self, + notificationContext: userSession.managedObjectContext.notificationContext + ) + } + + func didChangeAvailability(newState: NetworkState) { + dataSource.showsOfflineNotice = newState == .offline + } +} diff --git a/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/ConversationContentViewController.swift b/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/ConversationContentViewController.swift index 5cb22e0135b..8328e81cdbf 100644 --- a/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/ConversationContentViewController.swift +++ b/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/ConversationContentViewController.swift @@ -115,6 +115,7 @@ final class ConversationContentViewController: UIViewController { let messagePresenter: MessagePresenter var deletionDialogPresenter: DeletionDialogPresenter? let userSession: UserSession + var networkAvailabilityObserverToken: Any? let mainCoordinator: AnyMainCoordinator let selfProfileUIBuilder: SelfProfileViewControllerBuilderProtocol let conversationCreationRepository: any ConversationCreationRepositoryProtocol @@ -243,6 +244,8 @@ final class ConversationContentViewController: UIViewController { override func viewDidLoad() { super.viewDidLoad() + observeNetworkAvailabilityForOfflineNotice() + tableView.rowHeight = UITableView.automaticDimension tableView.estimatedRowHeight = 80 tableView.translatesAutoresizingMaskIntoConstraints = false diff --git a/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/ConversationTableViewDataSource.swift b/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/ConversationTableViewDataSource.swift index 10a4281b6f3..cfe7ecf3ac2 100644 --- a/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/ConversationTableViewDataSource.swift +++ b/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/ConversationTableViewDataSource.swift @@ -98,6 +98,16 @@ final class ConversationTableViewDataSource: NSObject { } } + /// Whether the notice that the device is offline and messages are sent later is shown. + var showsOfflineNotice = false { + didSet { + guard showsOfflineNotice != oldValue else { return } + calculateSections { [weak self] sections in + self?.reloadSections(newSections: sections, animated: true) + } + } + } + var allMessages: [ZMMessage] { // NOTE: We limit the number of messages to the `lastFetchedObjectCount` since the // NSFetchResultsController will add objects to `fetchObjects` if they are modified after @@ -837,10 +847,26 @@ extension ConversationTableViewDataSource { isFirstUnreadMessage: message.nonce == firstUnreadMessageNonce, isLastMessage: isLastMessage, searchQueries: searchQueries, - previousMessageIsKnock: previousMessage?.isKnock == true + previousMessageIsKnock: previousMessage?.isKnock == true, + offlineNotice: offlineNoticePosition(at: index, messages: messages) ) } + /// The offline notice goes above the messages the self user sent while offline, which are the newest ones + /// and still pending, or below the newest message if there are none. + private func offlineNoticePosition(at index: Int, messages: [ZMMessage]) -> OfflineNoticePosition? { + guard showsOfflineNotice, !hasNewerMessagesToLoad else { return nil } + + let pendingMessageCount = messages.prefix { message in + Message.isNormal(message) && message.deliveryState == .pending && message.sender?.isSelfUser == true + }.count + + if pendingMessageCount == 0 { + return index == 0 ? .below : nil + } + return index == pendingMessageCount - 1 ? .above : nil + } + private func isFirstMessageOfTheDay( for message: ZMConversationMessage, at index: Int,