From c030e38420560505c9d3277fee24461967cf4414 Mon Sep 17 00:00:00 2001 From: jack Date: Sun, 26 Jul 2026 13:37:07 +0200 Subject: [PATCH] Keep mirrored outgoing media when clearing another conversation Clearing conversation A removed outgoing media IDs from every direct conversation and unlinked the payload unconditionally, so a mirrored outgoing bubble in conversation B (identity-alias handoff) lost both its row and its file. Outgoing removal and file cleanup now mirror the incoming alias protection: only IDs with no surviving copy in another conversation are removed everywhere and unlinked. Co-Authored-By: Claude Opus 4.8 --- bitchat/ViewModels/ChatViewModel.swift | 19 ++++++++-- bitchatTests/ChatViewModelTests.swift | 52 ++++++++++++++++++++++++++ 2 files changed, 67 insertions(+), 4 deletions(-) diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index 336cdc1d..4ec2de2c 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -774,7 +774,6 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage } let outgoingMedia = request.outgoingMedia - let outgoingMediaIDs = Set(outgoingMedia.map(\.id)) let capturedExclusiveIDs = capturedMessageIDs.subtracting(survivingMessageIDs) @@ -877,12 +876,24 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage } } - for message in outgoingMedia { + // Outgoing media mirrors the incoming alias protection: an ID + // whose copy survives in a conversation this clear does not + // touch (identity-alias handoff) keeps that bubble and its local + // file. Only IDs with no surviving copy are removed from every + // direct conversation and have their payload unlinked. + let outgoingPlan = currentRemovalPlan() + let removableOutgoingMedia = outgoingMedia.filter { + !hasRemainingCopy(of: $0.id, after: outgoingPlan) + } + for message in removableOutgoingMedia { mediaTransferCoordinator.cleanupOutgoingLocalFile( forMessage: message ) } - if !outgoingMediaIDs.isEmpty { + let removableOutgoingIDs = Set( + removableOutgoingMedia.map(\.id) + ) + if !removableOutgoingIDs.isEmpty { let directConversationIDs = conversations .conversationsByID.keys.filter { if case .direct = $0 { return true } @@ -890,7 +901,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage } for conversationID in directConversationIDs { conversations.removeMessages(from: conversationID) { - outgoingMediaIDs.contains($0.id) + removableOutgoingIDs.contains($0.id) } } } diff --git a/bitchatTests/ChatViewModelTests.swift b/bitchatTests/ChatViewModelTests.swift index 477fcdc9..f384c963 100644 --- a/bitchatTests/ChatViewModelTests.swift +++ b/bitchatTests/ChatViewModelTests.swift @@ -1821,6 +1821,58 @@ struct ChatViewModelPrivateMediaDeletionTests { ) } + @Test @MainActor + func clearPrivateChatKeepsOutgoingMediaReferencedByAnotherConversation() + throws { + let (viewModel, transport) = makeTestableViewModel() + let firstPeerID = PeerID(str: String(repeating: "4", count: 64)) + let aliasPeerID = PeerID(str: String(repeating: "5", count: 64)) + let outgoingDirectory = try FileManager.default.url( + for: .applicationSupportDirectory, + in: .userDomainMask, + appropriateFor: nil, + create: true + ) + .appendingPathComponent("files/images/outgoing", isDirectory: true) + try FileManager.default.createDirectory( + at: outgoingDirectory, + withIntermediateDirectories: true + ) + let fileURL = outgoingDirectory.appendingPathComponent( + "outgoing-mirrored-\(UUID().uuidString).jpg" + ) + try Data("outgoing-image".utf8).write(to: fileURL, options: .atomic) + defer { try? FileManager.default.removeItem(at: fileURL) } + let message = privateMediaMessage( + id: UUID().uuidString, + sender: viewModel.nickname, + senderPeerID: transport.myPeerID, + recipient: "Peer", + filename: fileURL.lastPathComponent + ) + viewModel.seedPrivateChat([message], for: firstPeerID) + viewModel.seedPrivateChat([message], for: aliasPeerID) + + viewModel.clearPrivateChat(firstPeerID) + + // The mirrored conversation keeps its bubble and the payload file: + // clearing conversation A must not reach across an identity-alias + // handoff into conversation B. + #expect(transport.deletedPrivateMediaMessageIDBatches.isEmpty) + #expect(viewModel.privateChats[firstPeerID]?.isEmpty == true) + #expect( + viewModel.privateChats[aliasPeerID]?.map(\.id) == [message.id] + ) + #expect(FileManager.default.fileExists(atPath: fileURL.path)) + + // Clearing the last conversation that references the outgoing + // payload removes both the bubble and the file. + viewModel.clearPrivateChat(aliasPeerID) + + #expect((viewModel.privateChats[aliasPeerID] ?? []).isEmpty) + #expect(!FileManager.default.fileExists(atPath: fileURL.path)) + } + @Test @MainActor func clearPrivateChatLeavesAmbiguousLegacyFileForQuotaCleanup() throws {