mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-26 22:25:19 +00:00
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:
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user