From a44b9b745b2f59d3733f716bbc0b34271eb80861 Mon Sep 17 00:00:00 2001 From: Krainov Gleb Date: Sat, 3 Oct 2026 12:39:08 +0000 Subject: [PATCH 1/3] fix(chat): Mark voice message as failed when its upload fails When uploading a voice message failed, only an alert was shown. The temporary message stayed in the "sending" state, and as "Resend" and "Delete" are only offered for failed messages, it could neither be resent nor removed until it was marked as failed after 12 hours. Mark the temporary message as failed right away, in the database and in the open chat, so the existing "Resend" and "Delete" actions work. When resending, show and store the voice message as sending again. Before, it was shown as failed during the new upload and was removed from the database, so a second failure could not be kept. Signed-off-by: Krainov Gleb Assisted-by: Claude Code:claude-opus-5-5 --- .../Chat/BaseChatViewController.swift | 30 ++++++++++++++ .../Chat/UnitBaseChatViewControllerTest.swift | 40 +++++++++++++++++++ 2 files changed, 70 insertions(+) diff --git a/NextcloudTalk/Chat/BaseChatViewController.swift b/NextcloudTalk/Chat/BaseChatViewController.swift index 6fc3dd74e..69a402520 100644 --- a/NextcloudTalk/Chat/BaseChatViewController.swift +++ b/NextcloudTalk/Chat/BaseChatViewController.swift @@ -1371,6 +1371,14 @@ import Toast if message.messageType != kMessageTypeVoiceMessage { self.sendChatMessage(message: originalMessage, withParentMessage: message.parent, messageParameters: message.messageParametersJSONString ?? "", silently: message.isSilent) } else { + // Show and store the message as sending again, so it can be marked as failed again + message.sendingFailed = false + message.isOfflineMessage = false + + RLMRealm.writeTransaction { realm in + realm.addOrUpdate(NCChatMessage(value: message)) + } + if NCDatabaseManager.sharedInstance().roomHasTalkCapability(.chatReferenceId, for: room) { self.appendTemporaryMessage(temporaryMessage: message) } @@ -2245,11 +2253,33 @@ import Toast try await ChatFileUploader.upload(upload) NCLog.log("Successfully uploaded and shared \(upload.fileName)") } catch { + if let referenceId = upload.referenceId { + self.markTemporaryMessageAsFailed(referenceId: referenceId) + } + self.presentUploadError(error, for: upload) } } } + internal func markTemporaryMessageAsFailed(referenceId: String) { + // Allow to resend or delete the message, instead of showing it as sending until it expires + RLMRealm.writeTransaction { _ in + if let managedTemporaryMessage = NCChatMessage.objects(where: "referenceId = %@ AND isTemporary = true", referenceId).firstObject() as? NCChatMessage { + managedTemporaryMessage.sendingFailed = true + managedTemporaryMessage.isOfflineMessage = false + } + } + + self.modifyMessageWith(referenceId: referenceId) { message in + // The message might have been received from the server in the meantime + guard message.isTemporary else { return } + + message.sendingFailed = true + message.isOfflineMessage = false + } + } + private func presentUploadError(_ error: Error, for upload: ChatFileUpload) { let message: String diff --git a/NextcloudTalkTests/Unit/Chat/UnitBaseChatViewControllerTest.swift b/NextcloudTalkTests/Unit/Chat/UnitBaseChatViewControllerTest.swift index 83424e8f9..d4651807f 100644 --- a/NextcloudTalkTests/Unit/Chat/UnitBaseChatViewControllerTest.swift +++ b/NextcloudTalkTests/Unit/Chat/UnitBaseChatViewControllerTest.swift @@ -222,4 +222,44 @@ final class UnitBaseChatViewControllerTest: TestBaseRealm { XCTAssertEqual(baseController.getCellHeight(for: testMessage, with: 300), 171.0) } + func testMarkTemporaryMessageAsFailed() throws { + let temporaryMessage = NCChatMessage() + temporaryMessage.internalId = "temp-1" + temporaryMessage.referenceId = "reference-1" + temporaryMessage.messageType = "voice-message" + temporaryMessage.isTemporary = true + + // A message received from the server with the same reference id + let receivedMessage = NCChatMessage() + receivedMessage.messageId = 1 + receivedMessage.internalId = "internal-1" + receivedMessage.referenceId = "reference-1" + + try? realm.transaction { + realm.add(temporaryMessage) + realm.add(receivedMessage) + } + + baseController.markTemporaryMessageAsFailed(referenceId: "reference-1") + + XCTAssertTrue(temporaryMessage.sendingFailed) + XCTAssertFalse(temporaryMessage.isOfflineMessage) + XCTAssertFalse(receivedMessage.sendingFailed) + } + + func testMarkTemporaryMessageAsFailedIgnoresReceivedMessage() throws { + let receivedMessage = NCChatMessage() + receivedMessage.messageId = 1 + receivedMessage.internalId = "internal-1" + receivedMessage.referenceId = "reference-1" + + try? realm.transaction { + realm.add(receivedMessage) + } + + baseController.markTemporaryMessageAsFailed(referenceId: "reference-1") + + XCTAssertFalse(receivedMessage.sendingFailed) + } + } From 002d21748f796b8ddbaf47a155f4f2c8a4c88921 Mon Sep 17 00:00:00 2001 From: Krainov Gleb Date: Sat, 3 Oct 2026 12:39:16 +0000 Subject: [PATCH 2/3] fix(chat): Show the reason when uploading a voice message fails A failed upload of a voice message showed "Unknown error occurred". Show the description of the upload error instead, as the share view already does for other files. Signed-off-by: Krainov Gleb Assisted-by: Claude Code:claude-opus-5-5 --- NextcloudTalk/Chat/BaseChatViewController.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/NextcloudTalk/Chat/BaseChatViewController.swift b/NextcloudTalk/Chat/BaseChatViewController.swift index 69a402520..00b27e073 100644 --- a/NextcloudTalk/Chat/BaseChatViewController.swift +++ b/NextcloudTalk/Chat/BaseChatViewController.swift @@ -2300,6 +2300,9 @@ import Toast case ChatFileUploadError.tooManyRequests: NCLog.log("Too many requests while uploading") message = NSLocalizedString("Too many requests, please try again later", comment: "") + case ChatFileUploadError.uploadFailed(_, let errorDescription) where !errorDescription.isEmpty: + NCLog.log("Failed to upload \(upload.fileName). Error: \(errorDescription)") + message = errorDescription default: NCLog.log("Failed to upload \(upload.fileName). Error: \(error.localizedDescription)") message = NSLocalizedString("Unknown error occurred", comment: "") From ab871074639d75554ab4f27b3ea5b523eb8cff1f Mon Sep 17 00:00:00 2001 From: Krainov Gleb Date: Tue, 13 Oct 2026 14:56:40 +0000 Subject: [PATCH 3/3] fix(chat): Assign a new timestamp when resending a voice message The resent voice message kept the timestamp of the failed attempt. It was inserted at its old position in the chat instead of the end, possibly under an old date header, and getTemporaryMessages marked it as failed again after 12 hours counted from the first attempt. Assign the current time like a newly created temporary message has. Signed-off-by: Krainov Gleb Assisted-by: Claude Code:claude-opus-5-5 --- NextcloudTalk/Chat/BaseChatViewController.swift | 12 ++++++++++-- .../Unit/Chat/UnitBaseChatViewControllerTest.swift | 14 ++++++++++++++ 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/NextcloudTalk/Chat/BaseChatViewController.swift b/NextcloudTalk/Chat/BaseChatViewController.swift index 00b27e073..0b367fc75 100644 --- a/NextcloudTalk/Chat/BaseChatViewController.swift +++ b/NextcloudTalk/Chat/BaseChatViewController.swift @@ -1360,6 +1360,15 @@ import Toast NotificationPresenter.shared().present(text: NSLocalizedString("Message unpinned", comment: ""), dismissAfterDelay: 5.0, includedStyle: .success) } + internal func prepareVoiceMessageForResend(_ message: NCChatMessage) { + message.sendingFailed = false + message.isOfflineMessage = false + + // Like a resent text message, the message is sent now: it needs to be placed at the end of the chat + // and must not expire (getTemporaryMessages) based on the timestamp of the failed attempt + message.timestamp = Int(Date().timeIntervalSince1970) + } + func didPressResend(for message: NCChatMessage) { // Make sure there's no unread message separator, as the indexpath could be invalid after removing a message self.removeUnreadMessagesSeparator() @@ -1372,8 +1381,7 @@ import Toast self.sendChatMessage(message: originalMessage, withParentMessage: message.parent, messageParameters: message.messageParametersJSONString ?? "", silently: message.isSilent) } else { // Show and store the message as sending again, so it can be marked as failed again - message.sendingFailed = false - message.isOfflineMessage = false + self.prepareVoiceMessageForResend(message) RLMRealm.writeTransaction { realm in realm.addOrUpdate(NCChatMessage(value: message)) diff --git a/NextcloudTalkTests/Unit/Chat/UnitBaseChatViewControllerTest.swift b/NextcloudTalkTests/Unit/Chat/UnitBaseChatViewControllerTest.swift index d4651807f..c765ae6dd 100644 --- a/NextcloudTalkTests/Unit/Chat/UnitBaseChatViewControllerTest.swift +++ b/NextcloudTalkTests/Unit/Chat/UnitBaseChatViewControllerTest.swift @@ -262,4 +262,18 @@ final class UnitBaseChatViewControllerTest: TestBaseRealm { XCTAssertFalse(receivedMessage.sendingFailed) } + func testPrepareVoiceMessageForResendAssignsNewTimestamp() throws { + let message = NCChatMessage() + message.sendingFailed = true + message.isOfflineMessage = true + message.timestamp = Int(Date().timeIntervalSince1970) - (60 * 60 * 13) + + let before = Int(Date().timeIntervalSince1970) + baseController.prepareVoiceMessageForResend(message) + + XCTAssertFalse(message.sendingFailed) + XCTAssertFalse(message.isOfflineMessage) + XCTAssertGreaterThanOrEqual(message.timestamp, before) + } + }