Actually delete legacy incoming media on explicit delete and /clear

Explicit deletion of LEGACY (non-stable-ID) incoming media left the
decrypted payload on disk indefinitely, waiting for 100MB oldest-first
quota cleanup. Restore real deletion safely with the machinery this PR
built: BLEIncomingFileStore.removeLegacyIncomingFile unlinks under the
payload-coordination lock only when the path is not pending delivery,
not held by a deletion reservation, and not owned by a stable receipt
or journal entry; otherwise the file keeps the leave-for-quota
fallback. The view model routes per-bubble deletes and /clear through
the gated unlink once no bubble in any conversation references the
basename.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
jack
2026-07-26 13:44:10 +02:00
co-authored by Claude Opus 4.8
parent 867711506d
commit cc6e2bdad4
8 changed files with 350 additions and 55 deletions
@@ -226,18 +226,51 @@ extension ChatViewModel: ChatMediaTransferContext {
let message = conversations.conversationsByID.values.lazy
.flatMap(\.messages)
.first { $0.id == messageID }
if let message {
if !isIncomingPrivateMessage(message) {
mediaTransferCoordinator.cleanupOutgoingLocalFile(
forMessage: message
)
}
// Legacy/raw incoming media has no durable ID-to-file ownership.
// Its basename may already belong to a pending newer arrival, so
// leave the payload for bounded quota cleanup rather than unlink
// an ambiguous path.
if let message, !isIncomingPrivateMessage(message) {
mediaTransferCoordinator.cleanupOutgoingLocalFile(
forMessage: message
)
}
removeMessage(withID: messageID, cleanupFile: false)
if let message {
cleanupLegacyIncomingMediaPayloads(for: [message])
}
}
/// Explicitly deleted LEGACY (non-stable-ID) incoming media has no
/// durable ID-to-file ownership, so the actual unlink is delegated to
/// the transport's gated cleanup: a basename that is pending delivery or
/// reserved by a receipt/deletion transaction stays on disk for bounded
/// quota cleanup instead. Must run after the bubbles were removed; a
/// surviving reference in any conversation keeps the payload.
func cleanupLegacyIncomingMediaPayloads(for messages: [BitchatMessage]) {
guard let cleanup =
meshService as? any PrivateMediaDeletionPersisting else {
return
}
let legacyPaths = Set(messages.compactMap { message -> String? in
guard !PrivateMediaMessageIdentity.isStableID(message.id),
isIncomingPrivateMessage(message) else {
return nil
}
return incomingMediaRelativePath(for: message)
})
guard !legacyPaths.isEmpty else { return }
let survivingPaths = Set(
conversations.conversationsByID.values.lazy
.flatMap(\.messages)
.compactMap { message -> String? in
guard self.isIncomingPrivateMessage(message) else {
return nil
}
return self.incomingMediaRelativePath(for: message)
}
)
for relativePath in legacyPaths.subtracting(survivingPaths).sorted() {
cleanup.removeLegacyPrivateMediaPayload(
relativePath: relativePath
)
}
}
func removeOutgoingMediaMessage(withID messageID: String) {