Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions Session.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -1164,6 +1164,7 @@
FDFE75B42ABD46B600655640 /* MockUserDefaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD83B9D127D59495005E1583 /* MockUserDefaults.swift */; };
FDFE75B52ABD46B700655640 /* MockUserDefaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD83B9D127D59495005E1583 /* MockUserDefaults.swift */; };
FDFF9FDF2A787F57005E0628 /* JSONEncoder+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDFF9FDE2A787F57005E0628 /* JSONEncoder+Utilities.swift */; };
FE4234C92E9CDECD00F2C9F7 /* AttachmentManager+AttachmentDeleter.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE4234C82E9CDECD00F2C9F7 /* AttachmentManager+AttachmentDeleter.swift */; };
FED288F32E4C28CF00C31171 /* AppReviewPromptDialog.swift in Sources */ = {isa = PBXBuildFile; fileRef = FED288F22E4C28CF00C31171 /* AppReviewPromptDialog.swift */; };
FED288F82E4C3BE100C31171 /* AppReviewPromptModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FED288F72E4C3BE100C31171 /* AppReviewPromptModel.swift */; };
/* End PBXBuildFile section */
Expand Down Expand Up @@ -2450,6 +2451,7 @@
FDFDE129282D056B0098B17F /* MediaZoomAnimationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaZoomAnimationController.swift; sourceTree = "<group>"; };
FDFE75B02ABD2D2400655640 /* _030_MakeBrokenProfileTimestampsNullable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _030_MakeBrokenProfileTimestampsNullable.swift; sourceTree = "<group>"; };
FDFF9FDE2A787F57005E0628 /* JSONEncoder+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "JSONEncoder+Utilities.swift"; sourceTree = "<group>"; };
FE4234C82E9CDECD00F2C9F7 /* AttachmentManager+AttachmentDeleter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AttachmentManager+AttachmentDeleter.swift"; sourceTree = "<group>"; };
FED288F22E4C28CF00C31171 /* AppReviewPromptDialog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppReviewPromptDialog.swift; sourceTree = "<group>"; };
FED288F72E4C3BE100C31171 /* AppReviewPromptModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppReviewPromptModel.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
Expand Down Expand Up @@ -3213,6 +3215,7 @@
FD7115EA28C5D78E00B47552 /* ThreadSettingsViewModel.swift */,
9479981B2DD44AC5008F5CD5 /* ThreadNotificationSettingsViewModel.swift */,
FD7115F328C71EB200B47552 /* ThreadDisappearingMessagesSettingsViewModel.swift */,
FE4234C82E9CDECD00F2C9F7 /* AttachmentManager+AttachmentDeleter.swift */,
);
path = Settings;
sourceTree = "<group>";
Expand Down Expand Up @@ -7033,6 +7036,7 @@
7B0EFDF0275084AA00FFAAE7 /* CallMessageCell.swift in Sources */,
C3AAFFF225AE99710089E6DD /* AppDelegate.swift in Sources */,
FD10AF0C2AF32B9A007709E5 /* SessionListViewModel.swift in Sources */,
FE4234C92E9CDECD00F2C9F7 /* AttachmentManager+AttachmentDeleter.swift in Sources */,
942256822C23F8BB00C0FDBF /* InviteAFriendScreen.swift in Sources */,
B8BB82A5238F627000BA5194 /* HomeVC.swift in Sources */,
4521C3C01F59F3BA00B4C582 /* TextFieldHelper.swift in Sources */,
Expand Down
231 changes: 135 additions & 96 deletions Session/Conversations/ConversationVC+Interaction.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2352,102 +2352,7 @@ extension ConversationVC:
/// Retrieve the deletion actions for the selected message(s) of there are any
let messagesToDelete: [MessageViewModel] = [cellViewModel]

guard let deletionBehaviours: MessageViewModel.DeletionBehaviours = self.viewModel.deletionActions(for: messagesToDelete) else {
return
}

let modal: ConfirmationModal = ConfirmationModal(
info: ConfirmationModal.Info(
title: deletionBehaviours.title,
body: .radio(
explanation: ThemedAttributedString(string: deletionBehaviours.body),
warning: deletionBehaviours.warning.map { ThemedAttributedString(string: $0) },
options: deletionBehaviours.actions.map { action in
ConfirmationModal.Info.Body.RadioOptionInfo(
title: action.title,
enabled: action.state != .disabled,
selected: action.state == .enabledAndDefaultSelected,
accessibility: action.accessibility
)
}
),
confirmTitle: "delete".localized(),
confirmStyle: .danger,
cancelTitle: "cancel".localized(),
cancelStyle: .alert_text,
dismissOnConfirm: false,
onConfirm: { [weak self, dependencies = viewModel.dependencies] modal in
/// Determine the selected action index
let selectedIndex: Int = {
switch modal.info.body {
case .radio(_, _, let options):
return options
.enumerated()
.first(where: { _, value in value.selected })
.map { index, _ in index }
.defaulting(to: 0)

default: return 0
}
}()

/// Stop the messages audio if needed
messagesToDelete.forEach { cellViewModel in
self?.viewModel.stopAudioIfNeeded(for: cellViewModel)
}

/// Trigger the deletion behaviours
deletionBehaviours
.publisherForAction(at: selectedIndex, using: dependencies)
.showingBlockingLoading(
in: deletionBehaviours.requiresNetworkRequestForAction(at: selectedIndex) ?
self?.viewModel.navigatableState :
nil
)
.sinkUntilComplete(
receiveCompletion: { result in
DispatchQueue.main.async {
switch result {
case .finished:
modal.dismiss(animated: true) {
/// Dispatch after a delay because becoming the first responder can cause
/// an odd appearance animation
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(150)) {
self?.viewModel.showToast(
text: "deleteMessageDeleted"
.putNumber(messagesToDelete.count)
.localized(),
backgroundColor: .backgroundSecondary,
inset: (self?.inputAccessoryView?.frame.height ?? Values.mediumSpacing) + Values.smallSpacing
)
}
}

case .failure:
self?.viewModel.showToast(
text: "deleteMessageFailed"
.putNumber(messagesToDelete.count)
.localized(),
backgroundColor: .backgroundSecondary,
inset: (self?.inputAccessoryView?.frame.height ?? Values.mediumSpacing) + Values.smallSpacing
)
}
completion?()
}
}
)
},
afterClosed: { [weak self] in
self?.becomeFirstResponder()
}
)
)

/// Show the modal after a small delay so it doesn't look as weird with the context menu dismissal
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(Int(ContextMenuVC.dismissDurationPartOne * 1000))) { [weak self] in
self?.present(modal, animated: true)
self?.resignFirstResponder()
}
onDelete(messagesToDelete, completion: completion)
}

func save(_ cellViewModel: MessageViewModel, completion: (() -> Void)?) {
Expand Down Expand Up @@ -3348,4 +3253,138 @@ extension ConversationVC: MediaPresentationContextProvider {
func snapshotOverlayView(in coordinateSpace: UICoordinateSpace) -> (UIView, CGRect)? {
return self.navigationController?.navigationBar.generateSnapshot(in: coordinateSpace)
}


// MARK: - Delete Messages
func deleteAttachments(_ ids: Set<Int64>, completion: @escaping () -> Void) {
let messagesToDelete: [MessageViewModel] = viewModel.interactionData
.flatMap { $0.elements }
.filter { ids.contains($0.id) }

onDelete(messagesToDelete, fromAttachments: true) {
completion()
}
}

func onDelete(_ messagesToDelete: [MessageViewModel], fromAttachments: Bool = false, completion: (() -> Void)? = nil) {
guard let topViewController = viewModel.dependencies[singleton: .appContext].frontMostViewController else {
return
}

guard let deletionBehaviours: MessageViewModel.DeletionBehaviours = self.viewModel.deletionActions(for: messagesToDelete) else {
return
}

var alertContentForDeletion: (title: String, body: String) {
guard fromAttachments else {
return (
deletionBehaviours.title,
deletionBehaviours.body
)
}

// If items to delete contains other messages aside from attachments w/ message
return (
"deleteAttachments"
.putNumber(messagesToDelete.count)
.localized(),
"deleteAttachmentsDescription"
.putNumber(messagesToDelete.count)
.localized()
)
}

let modal: ConfirmationModal = ConfirmationModal(
info: ConfirmationModal.Info(
title: alertContentForDeletion.title,
body: .radio(
explanation: ThemedAttributedString(string: alertContentForDeletion.body),
warning: deletionBehaviours.warning.map { ThemedAttributedString(string: $0) },
options: deletionBehaviours.actions.map { action in
ConfirmationModal.Info.Body.RadioOptionInfo(
title: action.title,
enabled: action.state != .disabled,
selected: action.state == .enabledAndDefaultSelected,
accessibility: action.accessibility
)
}
),
confirmTitle: "delete".localized(),
confirmStyle: .danger,
cancelTitle: "cancel".localized(),
cancelStyle: .alert_text,
dismissOnConfirm: false,
onConfirm: { [weak self, dependencies = viewModel.dependencies] modal in
/// Determine the selected action index
let selectedIndex: Int = {
switch modal.info.body {
case .radio(_, _, let options):
return options
.enumerated()
.first(where: { _, value in value.selected })
.map { index, _ in index }
.defaulting(to: 0)

default: return 0
}
}()

/// Stop the messages audio if needed
messagesToDelete.forEach { cellViewModel in
self?.viewModel.stopAudioIfNeeded(for: cellViewModel)
}

/// Trigger the deletion behaviours
deletionBehaviours
.publisherForAction(at: selectedIndex, using: dependencies)
.showingBlockingLoading(
in: deletionBehaviours.requiresNetworkRequestForAction(at: selectedIndex) ?
self?.viewModel.navigatableState :
nil
)
.sinkUntilComplete(
receiveCompletion: { result in
DispatchQueue.main.async {
switch result {
case .finished:
modal.dismiss(animated: true) {
/// Dispatch after a delay because becoming the first responder can cause
/// an odd appearance animation
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(150)) {
self?.viewModel.showToast(
text: "deleteMessageDeleted"
.putNumber(messagesToDelete.count)
.localized(),
backgroundColor: .backgroundSecondary,
inset: (self?.inputAccessoryView?.frame.height ?? Values.mediumSpacing) + Values.smallSpacing
)
}
}

case .failure:
self?.viewModel.showToast(
text: "deleteMessageFailed"
.putNumber(messagesToDelete.count)
.localized(),
backgroundColor: .backgroundSecondary,
inset: (self?.inputAccessoryView?.frame.height ?? Values.mediumSpacing) + Values.smallSpacing
)
}
completion?()
}
}
)
},
afterClosed: { [weak self] in
self?.becomeFirstResponder()
}
)
)

/// Show the modal after a small delay so it doesn't look as weird with the context menu dismissal
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(Int(ContextMenuVC.dismissDurationPartOne * 1000))) { [weak self] in
topViewController.present(modal, animated: true)
self?.resignFirstResponder()
}
}
}
5 changes: 5 additions & 0 deletions Session/Conversations/ConversationVC.swift
Original file line number Diff line number Diff line change
Expand Up @@ -557,6 +557,11 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa
// conversation settings then we don't need to worry about the conversation getting marked as
// when when the user returns back through this view controller
self.viewModel.markAsRead(target: .thread, timestampMs: nil)

// Message deletion
self.viewModel.dependencies[singleton: .attachmentManager].willTriggerDeleteOption = { [weak self] (items, completion) in
self?.deleteAttachments(items, completion: completion)
}
}

override func viewWillAppear(_ animated: Bool) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved.

import Foundation
import SessionUtilitiesKit
import SessionMessagingKit

extension AttachmentManager {
// Reusable delete function for all the media preview instances
public func deleteAttachments(_ items: [MediaGalleryViewModel.Item], completion: @escaping () -> Void) {
let desiredIDSet: Set<Int64> = Set(items.map { $0.interactionId })

willTriggerDeleteOption?(desiredIDSet) {
completion()
}
}
}
2 changes: 2 additions & 0 deletions Session/Media Viewing & Editing/AllMediaViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,8 @@ extension AllMediaViewController: MediaTileViewControllerDelegate {
)
}
else {
self.navigationItem.hidesBackButton = false

self.navigationItem.rightBarButtonItem = UIBarButtonItem(
title: "select".localized(),
style: .plain,
Expand Down
39 changes: 2 additions & 37 deletions Session/Media Viewing & Editing/MediaPageViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -567,44 +567,9 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou

@objc public func didPressDelete(_ sender: Any) {
guard let itemToDelete: MediaGalleryViewModel.Item = self.currentItem else { return }

let actionSheet: UIAlertController = UIAlertController(title: nil, message: nil, preferredStyle: .actionSheet)
let deleteAction = UIAlertAction(
title: "clearMessagesForMe".localized(),
style: .destructive
) { [dependencies = viewModel.dependencies] _ in
dependencies[singleton: .storage].writeAsync { db in
_ = try Attachment
.filter(id: itemToDelete.attachment.id)
.deleteAll(db)

// Add the garbage collection job to delete orphaned attachment files
dependencies[singleton: .jobRunner].add(
db,
job: Job(
variant: .garbageCollection,
behaviour: .runOnce,
details: GarbageCollectionJob.Details(
typesToCollect: [.orphanedAttachmentFiles]
)
),
canStartJob: true
)

// Delete any interactions which had all of their attachments removed
try Interaction.deleteWhere(
db,
.filter(Interaction.Columns.id == itemToDelete.interactionId),
.hasAttachments(false)
)
}
}
actionSheet.addAction(UIAlertAction(title: "cancel".localized(), style: .cancel))
actionSheet.addAction(deleteAction)

Modal.setupForIPadIfNeeded(actionSheet, targetView: self.view)
self.present(actionSheet, animated: true)
viewModel.dependencies[singleton: .attachmentManager].deleteAttachments([itemToDelete]) {}
}


// MARK: - Video interaction

Expand Down
Loading