From ca669a4bc72dc4da02d815b6ec954851a874e015 Mon Sep 17 00:00:00 2001 From: jack Date: Sun, 26 Jul 2026 13:53:18 +0200 Subject: [PATCH] Surface refused media deletions in the affected chat A refused delete/clear (journal write failure, reservation conflict, path collision, capacity) only logged SecureLogger.error, so a wedged /clear looked like success. Refusals now post a localized system message (content.system.media_delete_refused, 30 locales) into the affected private chat via the existing system-message mechanism, wired at both the per-bubble delete and /clear refusal sites. Co-Authored-By: Claude Opus 4.8 --- bitchat/Localizable.xcstrings | 36 +++++++++++++++++++ .../ChatMediaTransferCoordinator.swift | 28 +++++++++++++++ bitchat/ViewModels/ChatViewModel.swift | 1 + ...MediaTransferCoordinatorContextTests.swift | 6 ++++ bitchatTests/ChatViewModelTests.swift | 24 ++++++++++--- 5 files changed, 90 insertions(+), 5 deletions(-) diff --git a/bitchat/Localizable.xcstrings b/bitchat/Localizable.xcstrings index e25b5b0f..c5f6e84d 100644 --- a/bitchat/Localizable.xcstrings +++ b/bitchat/Localizable.xcstrings @@ -1,6 +1,42 @@ { "sourceLanguage" : "en", "strings" : { + "content.system.media_delete_refused" : { + "comment" : "System message shown in the affected chat when an explicit media delete or /clear was refused and bubbles/files were kept", + "extractionState" : "manual", + "localizations" : { + "ar" : { "stringUnit" : { "state" : "needs_review", "value" : "تعذّر حذف بعض الوسائط. جرّب حذف الوسائط الأقدم أولاً." } }, + "bn" : { "stringUnit" : { "state" : "needs_review", "value" : "কিছু মিডিয়া মুছে ফেলা যায়নি। আগে পুরোনো মিডিয়া মুছে ফেলার চেষ্টা করুন।" } }, + "de" : { "stringUnit" : { "state" : "needs_review", "value" : "einige medien konnten nicht gelöscht werden. versuche zuerst, ältere medien zu löschen." } }, + "en" : { "stringUnit" : { "state" : "translated", "value" : "some media could not be deleted. try deleting older media first." } }, + "es" : { "stringUnit" : { "state" : "needs_review", "value" : "no se pudieron eliminar algunos archivos multimedia. prueba a eliminar primero los más antiguos." } }, + "fa" : { "stringUnit" : { "state" : "needs_review", "value" : "برخی رسانه‌ها حذف نشدند. ابتدا رسانه‌های قدیمی‌تر را حذف کنید." } }, + "fil" : { "stringUnit" : { "state" : "needs_review", "value" : "hindi ma-delete ang ilang media. subukang i-delete muna ang mas lumang media." } }, + "fr" : { "stringUnit" : { "state" : "needs_review", "value" : "impossible de supprimer certains médias. essaie d'abord de supprimer les médias plus anciens." } }, + "he" : { "stringUnit" : { "state" : "needs_review", "value" : "לא ניתן למחוק חלק מהמדיה. נסה קודם למחוק מדיה ישנה יותר." } }, + "hi" : { "stringUnit" : { "state" : "needs_review", "value" : "कुछ मीडिया हटाई नहीं जा सकी। पहले पुरानी मीडिया हटाने का प्रयास करें।" } }, + "id" : { "stringUnit" : { "state" : "needs_review", "value" : "sebagian media tidak dapat dihapus. coba hapus media yang lebih lama dulu." } }, + "it" : { "stringUnit" : { "state" : "needs_review", "value" : "impossibile eliminare alcuni contenuti multimediali. prova prima a eliminare quelli più vecchi." } }, + "ja" : { "stringUnit" : { "state" : "needs_review", "value" : "一部のメディアを削除できませんでした。先に古いメディアを削除してみてください。" } }, + "ko" : { "stringUnit" : { "state" : "needs_review", "value" : "일부 미디어를 삭제하지 못했습니다. 먼저 오래된 미디어를 삭제해 보세요." } }, + "ms" : { "stringUnit" : { "state" : "needs_review", "value" : "sesetengah media tidak dapat dipadamkan. cuba padamkan media yang lebih lama dahulu." } }, + "ne" : { "stringUnit" : { "state" : "needs_review", "value" : "केही मिडिया मेटाउन सकिएन। पहिले पुराना मिडिया मेटाउने प्रयास गर।" } }, + "nl" : { "stringUnit" : { "state" : "needs_review", "value" : "sommige media konden niet worden verwijderd. probeer eerst oudere media te verwijderen." } }, + "pl" : { "stringUnit" : { "state" : "needs_review", "value" : "nie udało się usunąć części multimediów. spróbuj najpierw usunąć starsze multimedia." } }, + "pt" : { "stringUnit" : { "state" : "needs_review", "value" : "não foi possível eliminar alguns ficheiros multimédia. tenta eliminar primeiro os mais antigos." } }, + "pt-BR" : { "stringUnit" : { "state" : "needs_review", "value" : "não foi possível excluir algumas mídias. tente excluir primeiro as mídias mais antigas." } }, + "ru" : { "stringUnit" : { "state" : "needs_review", "value" : "не удалось удалить часть медиафайлов. попробуй сначала удалить более старые." } }, + "sv" : { "stringUnit" : { "state" : "needs_review", "value" : "vissa medier kunde inte raderas. prova att radera äldre medier först." } }, + "ta" : { "stringUnit" : { "state" : "needs_review", "value" : "சில ஊடகங்களை நீக்க முடியவில்லை. முதலில் பழைய ஊடகங்களை நீக்க முயற்சிக்கவும்." } }, + "th" : { "stringUnit" : { "state" : "needs_review", "value" : "ไม่สามารถลบสื่อบางรายการได้ ลองลบสื่อที่เก่ากว่าก่อน" } }, + "tr" : { "stringUnit" : { "state" : "needs_review", "value" : "bazı medya silinemedi. önce daha eski medyayı silmeyi dene." } }, + "uk" : { "stringUnit" : { "state" : "needs_review", "value" : "не вдалося видалити частину медіафайлів. спробуй спочатку видалити старіші." } }, + "ur" : { "stringUnit" : { "state" : "needs_review", "value" : "کچھ میڈیا حذف نہیں ہو سکا۔ پہلے پرانا میڈیا حذف کرنے کی کوشش کریں۔" } }, + "vi" : { "stringUnit" : { "state" : "needs_review", "value" : "không thể xóa một số phương tiện. hãy thử xóa phương tiện cũ hơn trước." } }, + "zh-Hans" : { "stringUnit" : { "state" : "needs_review", "value" : "部分媒体无法删除。请先尝试删除较早的媒体。" } }, + "zh-Hant" : { "stringUnit" : { "state" : "needs_review", "value" : "部分媒體無法刪除。請先嘗試刪除較舊的媒體。" } } + } + }, "notification.action.wave" : { "comment" : "Title of the notification action button that sends a friendly wave back to a nearby person", "extractionState" : "manual", diff --git a/bitchat/ViewModels/ChatMediaTransferCoordinator.swift b/bitchat/ViewModels/ChatMediaTransferCoordinator.swift index 671b7683..bad3a7fb 100644 --- a/bitchat/ViewModels/ChatMediaTransferCoordinator.swift +++ b/bitchat/ViewModels/ChatMediaTransferCoordinator.swift @@ -91,6 +91,9 @@ protocol ChatMediaTransferContext: AnyObject { func removeUntombstonedMediaMessage(withID messageID: String) func removeOutgoingMediaMessage(withID messageID: String) func addSystemMessage(_ content: String) + /// Surfaces a refused explicit media deletion in the affected chat so a + /// wedged delete never looks like success. + func notifyMediaDeletionRefused(messageID: String) /// Signals that message state changed so observers refresh (e.g. `objectWillChange.send()`). func notifyUIChanged() @@ -350,6 +353,28 @@ extension ChatViewModel: ChatMediaTransferContext { } } + func notifyMediaDeletionRefused(messageID: String) { + let owningPeerID = privateChats.first { _, messages in + messages.contains { $0.id == messageID } + }?.key + notifyPrivateMediaDeletionRefused(peerID: owningPeerID) + } + + /// A refused deletion/clear previously surfaced only in SecureLogger, so + /// a wedged /clear looked like success. Tell the affected chat that its + /// bubbles and payloads were intentionally kept. + func notifyPrivateMediaDeletionRefused(peerID: PeerID?) { + let copy = String( + localized: "content.system.media_delete_refused", + comment: "System message when an explicit media delete or /clear was refused and bubbles/files were kept" + ) + if let peerID = peerID ?? selectedPrivateChatPeer { + addLocalPrivateSystemMessage(copy, to: peerID) + } else { + addSystemMessage(copy) + } + } + private func isIncomingPrivateMessage( _ message: BitchatMessage ) -> Bool { @@ -1176,6 +1201,9 @@ final class ChatMediaTransferCoordinator { "Refusing to delete private media without a durable tombstone id=\(messageID.prefix(12))…", category: .session ) + self.context.notifyMediaDeletionRefused( + messageID: messageID + ) return } self.finishMediaDeletion( diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index ee94b34b..79fabd87 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -837,6 +837,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage "Refusing to clear private chat without durable media tombstones peer=\(peerID.id.prefix(8))…", category: .session ) + notifyPrivateMediaDeletionRefused(peerID: peerID) completion() return } diff --git a/bitchatTests/ChatMediaTransferCoordinatorContextTests.swift b/bitchatTests/ChatMediaTransferCoordinatorContextTests.swift index b0114b3a..ad200113 100644 --- a/bitchatTests/ChatMediaTransferCoordinatorContextTests.swift +++ b/bitchatTests/ChatMediaTransferCoordinatorContextTests.swift @@ -84,6 +84,10 @@ private final class MockChatMediaTransferContext: ChatMediaTransferContext { } func addSystemMessage(_ content: String) { systemMessages.append(content) } + private(set) var mediaDeletionRefusals: [String] = [] + func notifyMediaDeletionRefused(messageID: String) { + mediaDeletionRefusals.append(messageID) + } func notifyUIChanged() { notifyUIChangedCount += 1 } // Delivery status & dedup @@ -643,6 +647,8 @@ struct ChatMediaTransferCoordinatorContextTests { #expect(context.removedMessages.isEmpty) #expect(context.cancelledTransfers == ["failed-delete"]) #expect(coordinator.messageIDToTransferId[messageID] == nil) + // The refusal must be visible in the affected chat, not just logged. + #expect(context.mediaDeletionRefusals == [messageID]) } @Test @MainActor diff --git a/bitchatTests/ChatViewModelTests.swift b/bitchatTests/ChatViewModelTests.swift index e640c5bc..2093359d 100644 --- a/bitchatTests/ChatViewModelTests.swift +++ b/bitchatTests/ChatViewModelTests.swift @@ -1272,9 +1272,13 @@ struct ChatViewModelPrivateMediaDeletionTests { "images/incoming/\(filename)" ]] ) + let messages = viewModel.privateChats[peerID] ?? [] + #expect(messages.prefix(2).map(\.id) == [stableID, legacyID]) + // The refusal is surfaced in the affected chat, not just logged. + #expect(messages.last?.sender == "system") #expect( - viewModel.privateChats[peerID]?.map(\.id) - == [stableID, legacyID] + messages.last?.content + == String(localized: "content.system.media_delete_refused") ) } @@ -1374,10 +1378,17 @@ struct ChatViewModelPrivateMediaDeletionTests { #expect( transport.deletedPrivateMediaMessageIDBatches == [[incomingID]] ) + let messages = viewModel.privateChats[peerID] ?? [] #expect( - viewModel.privateChats[peerID]?.map(\.id) + messages.prefix(3).map(\.id) == [incomingID, outgoingID, "ordinary-message"] ) + // The refused /clear is surfaced in the affected chat. + #expect(messages.last?.sender == "system") + #expect( + messages.last?.content + == String(localized: "content.system.media_delete_refused") + ) #expect(transport.cancelledTransfers == ["failed-clear-outgoing"]) #expect(viewModel.messageIDToTransferId[outgoingID] == nil) } @@ -1446,9 +1457,12 @@ struct ChatViewModelPrivateMediaDeletionTests { transport.deletedPrivateMediaRelativePaths == [[incomingID: "images/incoming/\(filename)"]] ) + let messages = viewModel.privateChats[peerID] ?? [] + #expect(messages.prefix(2).map(\.id) == [incomingID, outgoingID]) + #expect(messages.last?.sender == "system") #expect( - viewModel.privateChats[peerID]?.map(\.id) - == [incomingID, outgoingID] + messages.last?.content + == String(localized: "content.system.media_delete_refused") ) }