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 <noreply@anthropic.com>
This commit is contained in:
jack
2026-07-26 13:53:18 +02:00
co-authored by Claude Opus 4.8
parent cc6e2bdad4
commit ca669a4bc7
5 changed files with 90 additions and 5 deletions
+36
View File
@@ -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",
@@ -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(
+1
View File
@@ -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
}
@@ -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
+19 -5
View File
@@ -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")
)
}