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 <noreply@anthropic.com>
This commit is contained in:
jack
2026-07-26 14:15:38 +02:00
co-authored by Claude Opus 4.8
parent 3619ce081d
commit c030e38420
2 changed files with 67 additions and 4 deletions
+15 -4
View File
@@ -774,7 +774,6 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage
} }
let outgoingMedia = request.outgoingMedia let outgoingMedia = request.outgoingMedia
let outgoingMediaIDs = Set(outgoingMedia.map(\.id))
let capturedExclusiveIDs = let capturedExclusiveIDs =
capturedMessageIDs.subtracting(survivingMessageIDs) 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( mediaTransferCoordinator.cleanupOutgoingLocalFile(
forMessage: message forMessage: message
) )
} }
if !outgoingMediaIDs.isEmpty { let removableOutgoingIDs = Set(
removableOutgoingMedia.map(\.id)
)
if !removableOutgoingIDs.isEmpty {
let directConversationIDs = conversations let directConversationIDs = conversations
.conversationsByID.keys.filter { .conversationsByID.keys.filter {
if case .direct = $0 { return true } if case .direct = $0 { return true }
@@ -890,7 +901,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage
} }
for conversationID in directConversationIDs { for conversationID in directConversationIDs {
conversations.removeMessages(from: conversationID) { conversations.removeMessages(from: conversationID) {
outgoingMediaIDs.contains($0.id) removableOutgoingIDs.contains($0.id)
} }
} }
} }
+52
View File
@@ -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 @Test @MainActor
func clearPrivateChatLeavesAmbiguousLegacyFileForQuotaCleanup() func clearPrivateChatLeavesAmbiguousLegacyFileForQuotaCleanup()
throws { throws {