Skip to content
Open
Show file tree
Hide file tree
Changes from 12 commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
64e40cb
docs: add pending messages notes and asset follow-up sketch - WPB-26307
caldrian Oct 28, 2026
ccdcef5
docs: update asset follow-up notes with test results - WPB-26307
caldrian Oct 28, 2026
3036600
feat: keep pending assets in order with later messages - WPB-26307
caldrian Oct 28, 2026
59b7cee
Delete PENDING_MESSAGES_NOTES.md
caldrian Oct 28, 2026
6487724
docs: add asset PR tests and limitation to notes - WPB-26307
caldrian Oct 28, 2026
e13f770
feat: delete pending assets that are still uploading - WPB-26307
caldrian Oct 28, 2026
baa3807
Merge branch 'feat/pending-message-foundation-WPB-26307' into feat/of…
caldrian Oct 28, 2026
49bc79d
Merge branch 'feat/pending-message-foundation-WPB-26307' into feat/of…
caldrian Oct 29, 2026
18a3cdc
Merge branch 'feat/pending-message-foundation-WPB-26307' into feat/of…
caldrian Oct 29, 2026
50ba888
feat: show offline notice in conversation - WPB-25326
caldrian Oct 29, 2026
8444b56
Merge branch 'feat/pending-message-foundation-WPB-26307' into feat/of…
caldrian Oct 30, 2026
3b2ed87
Merge branch 'feat/offline-pending-assets-WPB-26307' into feat/offlin…
caldrian Oct 30, 2026
b0c870b
Merge branch 'feat/pending-message-foundation-WPB-26307' into feat/of…
caldrian Nov 2, 2026
1fd3008
Merge branch 'feat/offline-pending-assets-WPB-26307' into feat/offlin…
caldrian Nov 2, 2026
ba43a43
Merge branch 'feat/offline-system-message-WPB-25326' of github.com:wi…
caldrian Nov 2, 2026
8abd761
Merge branch 'feat/pending-message-foundation-WPB-26307' into feat/of…
caldrian Nov 4, 2026
928feae
Merge branch 'feat/offline-pending-assets-WPB-26307' into feat/offlin…
caldrian Nov 4, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<ZMClientMessage>(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<Message: ZMMessage>(
_ type: Message.Type,
matching predicate: NSPredicate,
in context: NSManagedObjectContext
) -> Message? {
let request = NSFetchRequest<Message>(entityName: type.entityName())
request.predicate = predicate
request.sortDescriptors = [NSSortDescriptor(key: #keyPath(ZMMessage.serverTimestamp), ascending: false)]
request.fetchLimit = 1
return context.fetchOrAssert(request: request).first
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
6 changes: 6 additions & 0 deletions wire-ios/Wire-iOS/Generated/Strings+Generated.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
Original file line number Diff line number Diff line change
@@ -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<ConversationOfflineNoticeCellDescription>,
ConversationMessageCell {

struct Configuration {
let attributedText: NSAttributedString
let icon: UIImage?
let iconTintColor: UIColor
}

override init(frame: CGRect) {
super.init(frame: frame)
lineView.isHidden = true

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (P2): Please set isAccessibilityElement = true on this view. The base cell hides its text and image from accessibility, so the assigned label is not exposed to VoiceOver.

}

@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

Check warning on line 67 in wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/Cells/ConfigurationMessageCell/Components/ConversationOfflineNoticeCell.swift

View workflow job for this annotation

GitHub Actions / Compiler warnings (Test Pull Request Changes)

Compiler warning

'SemanticColors' is deprecated: Use ColorScheme instead
let attributedText = NSMutableAttributedString(
string: Strings.title,
attributes: [.font: UIFont.systemFont(ofSize: 14, weight: .semibold), .foregroundColor: textColor]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (P2): These fixed 14-point fonts do not scale with Dynamic Type. Please use scaled fonts and update the text when the content-size category changes.

)
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
}
}
Loading
Loading