Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
67 commits
Select commit Hold shift + click to select a range
2ed224a
chore: carry pending message plan onto provisional feature base - WPB…
caldrian Oct 21, 2026
4697b6c
chore: specify pending message lifecycle and foundation contract - WP…
caldrian Oct 21, 2026
beaf1e7
feat: add pending message outbox foundation - WPB-26307
caldrian Oct 21, 2026
c512781
feat: queue regular messages across offline sessions - WPB-26307
caldrian Oct 21, 2026
86b31dd
feat: edit queued messages without losing drafts - WPB-26307
caldrian Oct 21, 2026
c58b886
feat: retain and recover queued classic assets - WPB-26307
caldrian Oct 21, 2026
1ea5d36
fix: serialize pending message retry and cancellation - WPB-26307
caldrian Oct 22, 2026
ef5e87b
fix: handle pending message save failures and asset teardown - WPB-26307
caldrian Oct 22, 2026
f982558
fix: guard pending message callbacks after account closure - WPB-26307
caldrian Oct 22, 2026
a70f2b9
fix: cancel pending message sync waits on account teardown - WPB-26307
caldrian Oct 22, 2026
dcbcb6f
Merge branch 'develop' of github.com:wireapp/wire-ios into feat/pendi…
caldrian Oct 22, 2026
c4e1614
fix: guard interrupted pending message preparation - WPB-26307
caldrian Oct 22, 2026
25ddf24
fix: require restart for pending message flag - WPB-26307
caldrian Oct 22, 2026
831e9ce
Merge branch 'develop' of github.com:wireapp/wire-ios into feat/pendi…
caldrian Oct 26, 2026
acdbe3a
chore: clarify pending message Wire Drive scope - WPB-26307
caldrian Oct 26, 2026
4da3683
Merge branch 'develop' of github.com:wireapp/wire-ios into feat/pendi…
caldrian Oct 26, 2026
c0048ae
fix: restore pending message model test build - WPB-26307
caldrian Oct 26, 2026
7501639
consolidate db changes
caldrian Oct 26, 2026
aa2b667
db changelog
caldrian Oct 26, 2026
af44697
Merge branch 'develop' of github.com:wireapp/wire-ios into feat/pendi…
caldrian Oct 26, 2026
a21ba80
docs: explain pending message enrollment and retention - WPB-26307
caldrian Oct 26, 2026
8e4e6d3
docs: explain pending message lifecycle and recovery invariants - WPB…
caldrian Oct 26, 2026
357e1de
Merge branch 'develop' of github.com:wireapp/wire-ios into feat/pendi…
caldrian Oct 27, 2026
574cac1
fix: restore pending message request strategy test build - WPB-26307
caldrian Oct 27, 2026
bab7b50
Merge branch 'develop' of github.com:wireapp/wire-ios into feat/pendi…
caldrian Oct 27, 2026
45578c9
minor fixes
caldrian Oct 27, 2026
be9096f
fix: serialize pending message deletion with send handoff - WPB-26307
caldrian Oct 27, 2026
fe27bfe
Merge branch 'develop' of github.com:wireapp/wire-ios into feat/pendi…
caldrian Oct 27, 2026
201561e
chore: add pending messages complexity review handover - WPB-26307
caldrian Oct 27, 2026
76f94b7
chore: add next-session prompt to review handover - WPB-26307
caldrian Oct 27, 2026
f6a1913
delete prompt
caldrian Oct 27, 2026
9263771
feat: keep messages pending while offline - WPB-26307
caldrian Oct 27, 2026
7ac67aa
feat: edit pending messages in place - WPB-26307
caldrian Oct 27, 2026
4289978
Merge branch 'develop' of github.com:wireapp/wire-ios into feat/pendi…
caldrian Oct 28, 2026
b961f6e
revert
caldrian Oct 28, 2026
654eda0
Merge branch 'develop' of https://github.com/wireapp/wire-ios into fe…
caldrian Oct 28, 2026
32a29b2
second attempt
caldrian Oct 28, 2026
40bde18
Merge remote-tracking branch 'caldrian/feat/offline-pending-messages-…
caldrian Oct 28, 2026
5dc6c2d
Merge commit '6f059a8106cb8893b34482b9922988f6cc964a43' into feat/off…
caldrian Oct 28, 2026
3ec088a
feat: delete pending messages that haven't been sent - WPB-26307
caldrian Oct 28, 2026
2596f3c
docs: add PR title and description for pending messages - WPB-26307
caldrian Oct 28, 2026
8044a06
review
caldrian Oct 28, 2026
b1b02c4
Merge remote-tracking branch 'caldrian/feat/offline-pending-messages-…
caldrian Oct 28, 2026
1cfb373
fix: address review findings for pending messages - WPB-26307
caldrian Oct 28, 2026
0363b96
Merge remote-tracking branch 'caldrian/feat/offline-pending-messages-…
caldrian Oct 28, 2026
54202aa
notes
caldrian Oct 28, 2026
b9bfd01
fix changelog
caldrian Oct 28, 2026
0d41372
Merge remote-tracking branch 'caldrian/feat/offline-pending-messages-…
caldrian Oct 28, 2026
c8cb7c4
Merge branch 'feat/pending-message-foundation-WPB-26307' into feat/pe…
caldrian Oct 28, 2026
394c9a5
fix: address review of offline pending messages - WPB-26307
caldrian Oct 29, 2026
d337043
fix: cancel pending sends in session teardown - WPB-26307
caldrian Oct 29, 2026
2633bf1
Merge branch 'develop' of https://github.com/wireapp/wire-ios into fe…
caldrian Oct 30, 2026
58f0b16
format code
caldrian Oct 30, 2026
9c892bb
fix: retry expired pending message requests - WPB-26307
caldrian Oct 30, 2026
90f5076
Merge branch 'develop' of https://github.com/wireapp/wire-ios into fe…
caldrian Nov 2, 2026
5a2db42
fix: preserve message send history across flag changes - WPB-26307
caldrian Nov 2, 2026
08cdd51
Merge branch 'develop' of github.com:wireapp/wire-ios into feat/pendi…
caldrian Nov 3, 2026
a35cb1e
fix: categorize offline pending messages developer flag - WPB-26307
caldrian Nov 3, 2026
7ce41c2
Merge branch 'develop' of github.com:wireapp/wire-ios into feat/pendi…
caldrian Nov 4, 2026
20d6a5a
Merge branch 'develop' of github.com:wireapp/wire-ios into feat/pendi…
caldrian Nov 4, 2026
15158cf
Merge branch 'develop' of github.com:wireapp/wire-ios into feat/pendi…
caldrian Nov 4, 2026
d4acdcf
Merge branch 'develop' of github.com:wireapp/wire-ios into feat/pendi…
caldrian Nov 4, 2026
f215f61
fix: build migration fixture with source schema fields - WPB-26307
caldrian Nov 4, 2026
2227b92
fix: record send attempts on the link-preview path - WPB-26307
caldrian Nov 4, 2026
51e88d7
fix: only send link preview after persisting send attempt - WPB-26307
caldrian Nov 4, 2026
27ec1e5
fix: fail message when send attempt cannot be persisted - WPB-26307
caldrian Nov 4, 2026
e2fb5ee
fix: persist send attempt together with every send - WPB-26307
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
4 changes: 4 additions & 0 deletions WireData/Sources/WireData/Schema/Databases Changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ As it is hard to spot changes from version to version of database models (.xcdat

* remove `Conversation.commitPendingProposalDate`, superseded by the `PendingProposalTimer` entity (values are moved by `PendingProposalTimerMigrationAction` when migrating to 2.143.0). Also remove the `@NSManaged` property in `ZMConversation` and its remaining usages.

### 2.146.0

* added `ClientMessage.sendAttemptStarted`, set before a pending message is first sent, so it is only edited in place while it cannot have reached the backend

### 2.145.0

* added optional `Meeting.invitedEmails` to preserve email invitees in the Meetings list.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,6 @@
<plist version="1.0">
<dict>
<key>_XCCurrentVersionName</key>
<string>zmessaging2.145.0.xcdatamodel</string>
<string>zmessaging2.146.0.xcdatamodel</string>
</dict>
</plist>

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ enum CoreDataMessagingMigrationVersion: String, CoreDataMigrationVersion {
// MARK: -

// Note: add new versions here in first position!
case v146 = "zmessaging2.146.0"
case v145 = "zmessaging2.145.0"
case v144 = "zmessaging2.144.0"
case v143 = "zmessaging2.143.0"
Expand Down Expand Up @@ -99,8 +100,10 @@ enum CoreDataMessagingMigrationVersion: String, CoreDataMigrationVersion {

var nextVersion: Self? {
switch self {
case .v145:
case .v146:
nil
case .v145:
.v146
case .v144:
.v145
case .v143:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -430,6 +430,8 @@ public extension ZMConversation {
}

let message = ZMClientMessage(nonce: nonce, managedObjectContext: moc)
// Only newly composed outgoing messages are known to have no previous send attempt.
message.sendAttemptStarted = false
configure?(message)

do {
Expand Down
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
deliveryState != .pending || (self as? ZMClientMessage)?.isDeletableWhilePending == true
}

@objc var hasBeenDeleted: Bool {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,104 @@ extension ZMClientMessage: TextMessageData {
linkPreviewState = fetchLinkPreview ? .waitingToBeProcessed : .done
linkAttachments = nil
delivered = false
sendAttemptStarted = false
}

// MARK: - Pending messages

/// Whether the message waits for the network instead of failing when the device is offline.
public var staysPendingWhileOffline: Bool {
DeveloperFlag.offlinePendingMessages.isOn && !isEphemeral
}

/// Whether the text can be replaced without sending an edit, because the message is pending and
/// hasn't been sent yet. Recipients then only ever see the new text, without an edited marker.
public var isEditableInPlace: Bool {
guard
DeveloperFlag.offlinePendingMessages.isOn,
!isEphemeral,
!isExpired,
!delivered,
!sendAttemptStarted,
let sender, sender.isSelfUser
else {
return false
}

switch underlyingMessage?.content {
case .text:
return true
case let .edited(messageEdit):
if case .text = messageEdit.content {
return true
}
return false
default:
return false
}
}

/// Creates the text for `replacePendingText(with:fetchLinkPreview:)`. Call it on the context of the
/// mentioned users.
public func makePendingText(_ text: String, mentions: [Mention]) -> Text {
Text(content: text, mentions: mentions, linkPreviews: [], replyingTo: quote as? ZMOTRMessage)
}

/// Replaces the text of a pending message that hasn't been sent yet, keeping its nonce.
///
/// Call it on the sync context, which also marks the message before sending it, so the text can't
/// change while a send reads it.
///
/// - Returns: `false` if the message is no longer editable in place, e.g. because sending started.
@discardableResult
public func replacePendingText(with text: Text, fetchLinkPreview: Bool) -> Bool {
guard isEditableInPlace, let nonce, let content = underlyingMessage?.content else { return false }

let updatedMessage: GenericMessage
switch content {
case let .edited(messageEdit):
guard let originalNonce = UUID(uuidString: messageEdit.replacingMessageID) else { return false }
updatedMessage = GenericMessage(
content: MessageEdit(replacingMessageID: originalNonce, text: text),
nonce: nonce
)
default:
updatedMessage = GenericMessage(content: text, nonce: nonce)
}

do {
try setUnderlyingMessage(updatedMessage)
} catch {
Logging.messageProcessing.warn("Failed to replace pending text. Reason: \(error.localizedDescription)")
return false
}

updateNormalizedText()
linkPreviewState = fetchLinkPreview ? .waitingToBeProcessed : .done
linkAttachments = nil
return true
}

/// Whether the message can be deleted without sending a delete, because it's a new pending message
/// that hasn't been sent yet, so nobody else has it.
public var isDeletableWhilePending: Bool {
guard isEditableInPlace, case .text = underlyingMessage?.content else { return false }
return true
}

/// Deletes a pending message that hasn't been sent yet, without sending a delete.
///
/// Call it on the sync context, which also marks the message before sending it, so it can't be
/// deleted while a send reads it.
///
/// - Returns: `false` if the message can no longer be deleted this way, e.g. because sending started.
@discardableResult
public func deletePendingMessage() -> Bool {
guard isDeletableWhilePending, let managedObjectContext else { return false }

removeClearingSender(true)
managedObjectContext.delete(self)
return true
}

var multipartAttachments: [GenericMessageProtocol.Attachment]? {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,11 @@ public class ZMClientMessage: ZMOTRMessage {
/// Link Preview state
@NSManaged public var updatedTimestamp: Date?

/// Whether a request with the current nonce may have reached the backend. Defaults to true for
/// stored messages with unknown send history. New outgoing messages explicitly start with false,
/// and every send attempt sets it to true regardless of the offline-pending feature flag.
@NSManaged public var sendAttemptStarted: Bool

/// In memory cache
var cachedUnderlyingMessage: GenericMessage?

Expand All @@ -44,7 +49,7 @@ public class ZMClientMessage: ZMOTRMessage {

open override var ignoredKeys: Set<AnyHashable>? {
(super.ignoredKeys ?? Set())
.union([#keyPath(updatedTimestamp)])
.union([#keyPath(updatedTimestamp), #keyPath(sendAttemptStarted)])
}

public override var updatedAt: Date? {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,9 @@
//

import Foundation
import GenericMessageProtocol
import WireDataModelSupport

@testable import WireDataModel

final class DatabaseMigrationTests_FixDuplicateOneOnOneConversations: XCTestCase {
Expand Down Expand Up @@ -75,14 +77,14 @@
oldOneOnOneConversation.messageProtocol = .proteus
oldOneOnOneConversation.userDefinedName = nil

try model.addTextMessages(
try Scaffolding.addTextMessages(
to: oldOneOnOneConversation,
messagePrefix: "oldConv from me",
sender: selfUser,
count: 3,
in: context
)
try model.addTextMessages(
try Scaffolding.addTextMessages(
to: oldOneOnOneConversation,
messagePrefix: "oldConv from otherUser",
sender: otherUser,
Expand All @@ -108,14 +110,14 @@
// setup
otherUser.setValue(newOneOnOneConversation, forKey: "oneOnOneConversation")

try model.addTextMessages(
try Scaffolding.addTextMessages(
to: newOneOnOneConversation,
messagePrefix: "newConv from me",
sender: selfUser,
count: 1,
in: context
)
try model.addTextMessages(
try Scaffolding.addTextMessages(
to: newOneOnOneConversation,
messagePrefix: "newConv from otherUser",
sender: otherUser,
Expand All @@ -134,7 +136,7 @@
postMigrationAction: { context in

try context.performAndWait {
let selfUser = ZMUser.selfUser(in: context)

Check warning on line 139 in wire-ios-data-model/Tests/ManagedObjectContext/DatabaseMigrationTests+FixDuplicateOneOnOneConversations.swift

View workflow job for this annotation

GitHub Actions / Compiler warnings (Test Pull Request Changes)

Compiler warning

Initialization of immutable value 'selfUser' was never used; consider replacing with assignment to '_' or removing it

let conversation = ZMConversation.fetch(
with: Scaffolding.oldOneOnOneConversationID,
Expand Down Expand Up @@ -181,5 +183,31 @@
static let otherUserQualifiedID = QualifiedID(uuid: .create(), domain: Self.domain)
static let oldOneOnOneConversationID = UUID(uuidString: "11118744-5514-45BB-A145-0B6F37856CDA")!
static let newOneOnOneConversationID = UUID(uuidString: "87CF8744-5514-45BB-A145-0B6F37856CDA")!

/// Creates messages using only fields available in the 2.118.0 source schema.
/// `ModelHelper.addTextMessages` calls today's composition API, which writes `sendAttemptStarted`
/// (added in 2.146.0). The old model has no setter for that property and raises an Objective-C exception.
/// Build the fixture directly so new composition requirements don't affect setup before migration.
static func addTextMessages(
to conversation: ZMConversation,
messagePrefix: String,
sender: ZMUser,
count: Int,
in context: NSManagedObjectContext
) throws {
for index in 0 ..< count {
let nonce = UUID()
let message = ZMClientMessage(nonce: nonce, managedObjectContext: context)
message.sender = sender
message.visibleInConversation = conversation

let messageData = ZMGenericMessageData.insertNewObject(in: context)
try messageData.setGenericMessage(GenericMessage(
content: Text(content: "\(messagePrefix) \(index)"),
nonce: nonce
))
messageData.message = message
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,44 @@ final class CoreDataMessagingMigrationVersionTests: XCTestCase {
)
}

func testMigrationFrom145To146TreatsPreviousSendHistoryAsUnknown() throws {
let helper = DatabaseMigrationHelper()
let sourceModel = try helper.createObjectModel(version: "2.145.0")
let destinationModel = try helper.createObjectModel(version: "2.146.0")
let mapping = try NSMappingModel.inferredMappingModel(
forSourceModel: sourceModel,
destinationModel: destinationModel
)
let storeDirectory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at: storeDirectory, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: storeDirectory) }
let nonce = UUID()

try helper.migrateStore(
sourceVersion: "2.145.0",
destinationVersion: "2.146.0",
mappingModel: mapping,
storeDirectory: storeDirectory,
preMigrationAction: { context in
try context.performAndWait {
let entity = try XCTUnwrap(NSEntityDescription.entity(forEntityName: "ClientMessage", in: context))
let message = NSManagedObject(entity: entity, insertInto: context)
message.setValue(nonce, forKey: "nonce")
try context.save()
}
},
postMigrationAction: { context in
try context.performAndWait {
let messages = try context.fetch(NSFetchRequest<NSManagedObject>(entityName: "ClientMessage"))
XCTAssertEqual(messages.count, 1)
let message = try XCTUnwrap(messages.first)
XCTAssertEqual(message.value(forKey: "nonce") as? UUID, nonce)
XCTAssertEqual(message.value(forKey: "sendAttemptStarted") as? Bool, true)
}
}
)
}

func testMigrationFrom144To145PreservesMeetingAndDefaultsInvitedEmails() throws {
let helper = DatabaseMigrationHelper()
let sourceModel = try helper.createObjectModel(version: "2.144.0")
Expand Down
Loading
Loading