Browse Source

Chatlist multiselect (#1604)

* add UILongPressGestureRecognizer in ContactCell to prepare multiselect

* enable and cancel multi-select mode in ChatListController

* implement editing bar at the bottom of the chat list

* don't pass member variable editingBar as parameter to addEditingView()

* archive, pin and delete multiple selected chats

* preselect chat list cell that has been long-pressed

* configure pin/unpin archive/unarcive buttons correctly, adapt pinning behavior according to android

* change method visibility

* ask before performing batch deletion

* mutual exclude search and multi-select in chatlist for now

* disable multiselection editing mode if last cell was deselected

* don't allow chat list multi-select during forwarding

* fix disappearing multi-select cancel button

* in order to avoid tracking the selected chatIDs, postpone reloads of the tableView while the chat list is in editing mode

* don't allow to select the archive cell in multi-select

* immediately switch to multi-select edit mode in chat list after long tap was detected

* fix new button visibility in chat list

* implement own long-tap editing method instead of overriding ChatListController's setEditing method, in order to handle swipe-to-edit correctly
cyBerta 2 years ago
parent
commit
6c97b60e20

+ 4 - 0
deltachat-ios.xcodeproj/project.pbxproj

@@ -113,6 +113,7 @@
 		30E8F253244DAD0E00CE2C90 /* SendingController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30E8F252244DAD0E00CE2C90 /* SendingController.swift */; };
 		30EF7308252F6A3300E2C54A /* PaddingTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30F4BFED252E3E020006B9B3 /* PaddingTextView.swift */; };
 		30EF7324252FF15F00E2C54A /* MessageLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30EF7323252FF15F00E2C54A /* MessageLabel.swift */; };
+		30F4E2942859213400ACA0D8 /* ChatListEditingBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30F4E2932859213300ACA0D8 /* ChatListEditingBar.swift */; };
 		30F8817624DA97DA0023780E /* BackgroundContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30F8817524DA97DA0023780E /* BackgroundContainer.swift */; };
 		30F9B9EC235F2116006E7ACF /* MessageCounter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30F9B9EB235F2116006E7ACF /* MessageCounter.swift */; };
 		30FDB70524D1C1000066C48D /* ChatViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30FDB6F824D1C1000066C48D /* ChatViewController.swift */; };
@@ -385,6 +386,7 @@
 		30E8F252244DAD0E00CE2C90 /* SendingController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SendingController.swift; sourceTree = "<group>"; };
 		30EF7323252FF15F00E2C54A /* MessageLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageLabel.swift; sourceTree = "<group>"; };
 		30F4BFED252E3E020006B9B3 /* PaddingTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaddingTextView.swift; sourceTree = "<group>"; };
+		30F4E2932859213300ACA0D8 /* ChatListEditingBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatListEditingBar.swift; sourceTree = "<group>"; };
 		30F8817524DA97DA0023780E /* BackgroundContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundContainer.swift; sourceTree = "<group>"; };
 		30F9B9EB235F2116006E7ACF /* MessageCounter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageCounter.swift; sourceTree = "<group>"; };
 		30FDB6F824D1C1000066C48D /* ChatViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ChatViewController.swift; sourceTree = "<group>"; };
@@ -1000,6 +1002,7 @@
 				AED62BCD247687E6009E220D /* LocationStreamingIndicator.swift */,
 				30F4BFED252E3E020006B9B3 /* PaddingTextView.swift */,
 				305DDD8625DD97BF00974489 /* DynamicFontButton.swift */,
+				30F4E2932859213300ACA0D8 /* ChatListEditingBar.swift */,
 			);
 			path = View;
 			sourceTree = "<group>";
@@ -1435,6 +1438,7 @@
 				AE77838F23E4276D0093EABD /* ContactCellViewModel.swift in Sources */,
 				B20462E62440C99600367A57 /* SettingsAutodelSetController.swift in Sources */,
 				3015634423A003BA00E9DEF4 /* AudioRecorderController.swift in Sources */,
+				30F4E2942859213400ACA0D8 /* ChatListEditingBar.swift in Sources */,
 				AE57C0802552BBD0003CFE70 /* GalleryItem.swift in Sources */,
 				AE25F09022807AD800CDEA66 /* AvatarSelectionCell.swift in Sources */,
 				30A4149724F6EFBE00EC91EB /* InfoMessageCell.swift in Sources */,

+ 159 - 10
deltachat-ios/Controller/ChatListController.swift

@@ -59,6 +59,16 @@ class ChatListController: UITableViewController {
         return label
     }()
 
+    private lazy var editingBar: ChatListEditingBar = {
+        let editingBar = ChatListEditingBar()
+        editingBar.translatesAutoresizingMaskIntoConstraints = false
+        editingBar.delegate = self
+        editingBar.showArchive = !isArchive
+        return editingBar
+    }()
+
+    private var editingConstraints: NSLayoutConstraintSet?
+
     init(dcContext: DcContext, isArchive: Bool) {
         self.dcContext = dcContext
         self.isArchive = isArchive
@@ -86,9 +96,6 @@ class ChatListController: UITableViewController {
     // MARK: - lifecycle
     override func viewDidLoad() {
         super.viewDidLoad()
-        if !isArchive {
-            navigationItem.rightBarButtonItem = newButton
-        }
         configureTableView()
         setupSubviews()
 
@@ -246,6 +253,7 @@ class ChatListController: UITableViewController {
         tableView.register(ContactCell.self, forCellReuseIdentifier: deadDropCellReuseIdentifier)
         tableView.register(ContactCell.self, forCellReuseIdentifier: contactCellReuseIdentifier)
         tableView.rowHeight = ContactCell.cellHeight
+        tableView.allowsMultipleSelectionDuringEditing = true
     }
 
     private var isInitial = true
@@ -296,10 +304,14 @@ class ChatListController: UITableViewController {
     }
 
     @objc func cancelButtonPressed() {
-        // cancel forwarding
-        RelayHelper.shared.cancel()
-        updateTitle()
-        refreshInBg()
+        if tableView.isEditing {
+            self.setLongTapEditing(false)
+        } else {
+            // cancel forwarding
+            RelayHelper.shared.cancel()
+            updateTitle()
+            refreshInBg()
+        }
     }
 
     override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
@@ -342,6 +354,7 @@ class ChatListController: UITableViewController {
             } else if let chatCell = tableView.dequeueReusableCell(withIdentifier: chatCellReuseIdentifier, for: indexPath) as? ContactCell {
                 // default chatCell
                 chatCell.updateCell(cellViewModel: cellData)
+                chatCell.delegate = self
                 return chatCell
             }
         case .contact:
@@ -361,11 +374,34 @@ class ChatListController: UITableViewController {
         return viewModel?.titleForHeaderIn(section: section)
     }
 
+    override func tableView(_ tableView: UITableView, willSelectRowAt indexPath: IndexPath) -> IndexPath? {
+        if !tableView.isEditing {
+            return indexPath
+        }
+
+        let cell = tableView.cellForRow(at: indexPath)
+        return cell == archiveCell ? nil : indexPath
+    }
+
+    override func tableView(_ tableView: UITableView, didDeselectRowAt indexPath: IndexPath) {
+        if tableView.isEditing,
+           let viewModel = viewModel {
+            editingBar.showUnpinning = viewModel.hasOnlyPinnedChatsSelected(in: tableView.indexPathsForSelectedRows)
+            if tableView.indexPathsForSelectedRows == nil {
+                setLongTapEditing(false)
+            }
+        }
+    }
+
     override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
         guard let viewModel = viewModel else {
             tableView.deselectRow(at: indexPath, animated: false)
             return
         }
+        if tableView.isEditing {
+            editingBar.showUnpinning = viewModel.hasOnlyPinnedChatsSelected(in: tableView.indexPathsForSelectedRows)
+            return
+        }
 
         let cellData = viewModel.cellDataFor(section: indexPath.section, row: indexPath.row)
         switch cellData.type {
@@ -424,29 +460,94 @@ class ChatListController: UITableViewController {
         return [archiveAction, pinAction, deleteAction]
     }
 
+    func setLongTapEditing(_ editing: Bool) {
+        tableView.setEditing(editing, animated: true)
+        viewModel?.setEditing(editing)
+        if editing {
+            addEditingView()
+            if let viewModel = viewModel {
+                editingBar.showUnpinning = viewModel.hasOnlyPinnedChatsSelected(in: tableView.indexPathsForSelectedRows)
+            }
+            archiveCell.selectionStyle = .none
+        } else {
+            removeEditingView()
+            archiveCell.selectionStyle = .default
+        }
+        updateTitle()
+    }
+
+    private func addEditingView() {
+        guard let appDelegate = UIApplication.shared.delegate as? AppDelegate,
+              let tabBarController = appDelegate.window?.rootViewController as? UITabBarController
+        else { return }
+
+        if !tabBarController.view.subviews.contains(editingBar) {
+            tabBarController.tabBar.subviews.forEach { view in
+                view.isHidden = true
+            }
+
+            tabBarController.view.addSubview(editingBar)
+            editingConstraints = NSLayoutConstraintSet(top: editingBar.constraintAlignTopTo(tabBarController.tabBar),
+                                                      bottom: editingBar.constraintAlignBottomTo(tabBarController.tabBar),
+                                                      left: editingBar.constraintAlignLeadingTo(tabBarController.tabBar),
+                                                      right: editingBar.constraintAlignTrailingTo(tabBarController.tabBar))
+            editingConstraints?.activate()
+        }
+    }
+
+    private func removeEditingView() {
+        guard let appDelegate = UIApplication.shared.delegate as? AppDelegate,
+              let tabBarController = appDelegate.window?.rootViewController as? UITabBarController
+        else { return }
+
+        editingBar.removeFromSuperview()
+        editingConstraints?.deactivate()
+        editingConstraints = nil
+        tabBarController.tabBar.subviews.forEach { view in
+            view.isHidden = false
+        }
+    }
+
     // MARK: updates
     private func updateTitle() {
         titleView.accessibilityHint = String.localized("a11y_connectivity_hint")
         if RelayHelper.shared.isForwarding() {
+            // multi-select is not allowed during forwarding
             titleView.text = String.localized("forward_to")
             if !isArchive {
                 navigationItem.setLeftBarButton(cancelButton, animated: true)
             }
         } else if isArchive {
             titleView.text = String.localized("chat_archived_chats_title")
-            navigationItem.setLeftBarButton(nil, animated: true)
-           
+            handleMultiSelectTitle()
         } else {
             titleView.text = DcUtils.getConnectivityString(dcContext: dcContext, connectedString: String.localized("pref_chats"))
             if dcContext.getConnectivity() >= DC_CONNECTIVITY_CONNECTED {
                 titleView.accessibilityHint = "\(String.localized("connectivity_connected")): \(String.localized("a11y_connectivity_hint"))"
             }
-            navigationItem.setLeftBarButton(nil, animated: true)
+            handleMultiSelectTitle()
         }
         titleView.sizeToFit()
     }
 
+    func handleMultiSelectTitle() {
+        if tableView.isEditing {
+            navigationItem.setLeftBarButton(cancelButton, animated: true)
+            navigationItem.setRightBarButton(nil, animated: true)
+        } else {
+            navigationItem.setLeftBarButton(nil, animated: true)
+            if !isArchive {
+                navigationItem.setRightBarButton(newButton, animated: true)
+            }
+        }
+        titleView.isUserInteractionEnabled = !tableView.isEditing
+    }
+
     func handleChatListUpdate() {
+        if tableView.isEditing {
+            viewModel?.setPendingChatListUpdate()
+            return
+        }
         if Thread.isMainThread {
             tableView.reloadData()
             handleEmptyStateLabel()
@@ -539,6 +640,25 @@ class ChatListController: UITableViewController {
         self.present(alert, animated: true, completion: nil)
     }
 
+    private func showDeleteMultipleChatConfirmationAlert() {
+        let selected = tableView.indexPathsForSelectedRows?.count ?? 0
+        if selected == 0 {
+            return
+        }
+        let alert = UIAlertController(
+            title: nil,
+            message: String.localized(stringID: "ask_delete_chat", count: selected),
+            preferredStyle: .safeActionSheet
+        )
+        alert.addAction(UIAlertAction(title: String.localized("delete"), style: .destructive, handler: { [weak self] _ in
+            guard let self = self, let viewModel = self.viewModel else { return }
+            viewModel.deleteChats(indexPaths: self.tableView.indexPathsForSelectedRows)
+            self.setLongTapEditing(false)
+        }))
+        alert.addAction(UIAlertAction(title: String.localized("cancel"), style: .cancel, handler: nil))
+        self.present(alert, animated: true, completion: nil)
+    }
+
     private func askToChatWith(address: String, contactId: Int = 0) {
         let alert = UIAlertController(title: String.localizedStringWithFormat(String.localized("ask_start_chat_with"), address),
                                       message: nil,
@@ -615,6 +735,7 @@ class ChatListController: UITableViewController {
 extension ChatListController: UISearchBarDelegate {
     func searchBarShouldBeginEditing(_ searchBar: UISearchBar) -> Bool {
         viewModel?.beginSearch()
+        setLongTapEditing(false)
         return true
     }
 
@@ -631,3 +752,31 @@ extension ChatListController: UISearchBarDelegate {
         return true
     }
 }
+
+extension ChatListController: ContactCellDelegate {
+    func onLongTap(at indexPath: IndexPath) {
+        if let searchActive = viewModel?.searchActive,
+           !searchActive,
+           !RelayHelper.shared.isForwarding(),
+           !tableView.isEditing {
+            setLongTapEditing(true)
+            tableView.selectRow(at: indexPath, animated: true, scrollPosition: .none)
+        }
+    }
+}
+
+extension ChatListController: ChatListEditingBarDelegate {
+    func onPinButtonPressed() {
+        viewModel?.pinChatsToggle(indexPaths: tableView.indexPathsForSelectedRows)
+        setLongTapEditing(false)
+    }
+
+    func onDeleteButtonPressed() {
+        showDeleteMultipleChatConfirmationAlert()
+    }
+
+    func onArchiveButtonPressed() {
+        viewModel?.archiveChatsToggle(indexPaths: tableView.indexPathsForSelectedRows)
+        setLongTapEditing(false)
+    }
+}

+ 10 - 0
deltachat-ios/Helper/Utils.swift

@@ -70,4 +70,14 @@ struct Utils {
         }
         return directoryURL.appendingPathComponent(identifier).appendingPathComponent(name)
     }
+
+    public static func getSafeBottomLayoutInset() -> CGFloat {
+        if #available(iOS 13.0, *) {
+            let window = UIApplication.shared.windows.first
+            return window?.safeAreaInsets.bottom ?? 0
+        }
+        // iOS 11 and 12
+        let window = UIApplication.shared.keyWindow
+        return window?.safeAreaInsets.bottom ?? 0
+    }
 }

+ 127 - 0
deltachat-ios/View/ChatListEditingBar.swift

@@ -0,0 +1,127 @@
+import UIKit
+
+public protocol ChatListEditingBarDelegate: class {
+    func onPinButtonPressed()
+    func onDeleteButtonPressed()
+    func onArchiveButtonPressed()
+}
+
+class ChatListEditingBar: UIView {
+
+    weak var delegate: ChatListEditingBarDelegate?
+
+    var showUnpinning: Bool? {
+        didSet {
+            guard let showUnpinning = showUnpinning else { return }
+            let imageName = showUnpinning ? "pin.slash" : "pin"
+            let description = showUnpinning ? String.localized("unpin") :  String.localized("pin")
+            configureButtonLayout(pinButton, imageName: imageName, imageDescription: description)
+        }
+    }
+
+    var showArchive: Bool? {
+        didSet {
+            guard let showArchive = showArchive else { return }
+            let imageName = showArchive ? "tray.and.arrow.down" : "tray.and.arrow.up"
+            let description = showArchive ? String.localized("archive") : String.localized("unarchive")
+            configureButtonLayout(archiveButton, imageName: imageName, imageDescription: description)
+        }
+    }
+
+    private lazy var blurView: UIVisualEffectView = {
+        var blurEffect = UIBlurEffect(style: .light)
+        if #available(iOS 13, *) {
+            blurEffect = UIBlurEffect(style: .systemMaterial)
+        }
+        let view = UIVisualEffectView(effect: blurEffect)
+        view.translatesAutoresizingMaskIntoConstraints = false
+        return view
+    }()
+
+    private lazy var mainContentView: UIStackView = {
+        let view = UIStackView(arrangedSubviews: [pinButton, archiveButton, deleteButton])
+        view.axis = .horizontal
+        view.distribution = .fillEqually
+        view.alignment = .fill
+        view.translatesAutoresizingMaskIntoConstraints = false
+        return view
+    }()
+
+    private lazy var deleteButton: UIButton = {
+        return createUIButton(imageName: "trash", imageDescription: String.localized("delete"))
+    }()
+
+    private lazy var archiveButton: UIButton = {
+        return createUIButton(imageName: "tray.and.arrow.down", imageDescription: String.localized("archive"))
+    }()
+
+    private lazy var pinButton: UIButton = {
+        return createUIButton(imageName: "pin", imageDescription: String.localized("pin"))
+    }()
+
+    public override init(frame: CGRect) {
+        super.init(frame: frame)
+        configureSubviews()
+    }
+
+    required init?(coder: NSCoder) {
+        fatalError("init(coder:) has not been implemented")
+    }
+    
+    private func createUIButton(imageName: String, imageDescription: String) -> UIButton {
+        let button = UIButton()
+        button.translatesAutoresizingMaskIntoConstraints = false
+        button.isUserInteractionEnabled = true
+        button.imageView?.contentMode = .scaleAspectFit
+        configureButtonLayout(button, imageName: imageName, imageDescription: imageDescription)
+        return button
+    }
+    
+    private func configureButtonLayout(_ button: UIButton, imageName: String, imageDescription: String) {
+        if #available(iOS 13.0, *) {
+            button.setImage(UIImage(systemName: imageName), for: .normal)
+            button.tintColor = .systemBlue
+        } else {
+            button.setTitle(description, for: .normal)
+            button.setTitleColor(.systemBlue, for: .normal)
+        }
+        button.accessibilityLabel = description
+    }
+
+    private func configureSubviews() {
+        self.addSubview(blurView)
+        self.addSubview(mainContentView)
+        blurView.fillSuperview()
+        addConstraints([
+            mainContentView.constraintAlignTopTo(self),
+            mainContentView.constraintAlignLeadingTo(self),
+            mainContentView.constraintAlignTrailingTo(self),
+            mainContentView.constraintAlignBottomTo(self, paddingBottom: Utils.getSafeBottomLayoutInset())
+        ])
+
+        let pinBtnGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(pinButtonPressed))
+        pinBtnGestureRecognizer.numberOfTapsRequired = 1
+        pinButton.addGestureRecognizer(pinBtnGestureRecognizer)
+
+        let deleteBtnGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(deleteButtonPressed))
+        deleteBtnGestureRecognizer.numberOfTapsRequired = 1
+        deleteButton.addGestureRecognizer(deleteBtnGestureRecognizer)
+
+        let archiveBtnGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(archiveButtonPressed))
+        archiveBtnGestureRecognizer.numberOfTapsRequired = 1
+        archiveButton.addGestureRecognizer(archiveBtnGestureRecognizer)
+    }
+
+    @objc func pinButtonPressed() {
+        delegate?.onPinButtonPressed()
+    }
+
+    @objc func deleteButtonPressed() {
+        delegate?.onDeleteButtonPressed()
+    }
+
+    @objc func archiveButtonPressed() {
+        delegate?.onArchiveButtonPressed()
+    }
+
+}

+ 13 - 1
deltachat-ios/View/ContactCell.swift

@@ -2,7 +2,7 @@ import UIKit
 import DcCore
 
 protocol ContactCellDelegate: class {
-    func onAvatarTapped(at index: Int)
+    func onLongTap(at indexPath: IndexPath)
 }
 
 class ContactCell: UITableViewCell {
@@ -210,8 +210,20 @@ class ContactCell: UITableViewCell {
             mutedIndicator.constraintHeightTo(titleLabel.font.pointSize * 1.2),
             locationStreamingIndicator.constraintHeightTo(titleLabel.font.pointSize * 1.2)
         ])
+
+        let gestureRecognizer = UILongPressGestureRecognizer(target: self, action: #selector(onLongTap))
+        contentView.addGestureRecognizer(gestureRecognizer)
     }
 
+    @objc private func onLongTap(sender: UILongPressGestureRecognizer) {
+        if sender.state == UIGestureRecognizer.State.began,
+           let tableView = self.superview as? UITableView,
+           let indexPath = tableView.indexPath(for: self) {
+            delegate?.onLongTap(at: indexPath)
+        }
+    }
+
+
     func setVerified(isVerified: Bool) {
         avatar.setVerified(isVerified)
     }

+ 86 - 13
deltachat-ios/ViewModel/ChatListViewModel.swift

@@ -41,6 +41,9 @@ class ChatListViewModel: NSObject {
     private var searchResultsMessagesSection: ChatListSectionType = .messages
     private var searchResultSections: [ChatListSectionType] = []
 
+    private var isChatListUpdatePending = false
+    private var isEditing = false
+
     init(dcContext: DcContext, isArchive: Bool) {
         self.isArchive = isArchive
         self.dcContext = dcContext
@@ -56,7 +59,18 @@ class ChatListViewModel: NSObject {
             gclFlags |= DC_GCL_FOR_FORWARDING
         }
         self.chatList = dcContext.getChatlist(flags: gclFlags, queryString: nil, queryId: 0)
-        if notifyListener, let onChatListUpdate = onChatListUpdate {
+        if notifyListener {
+            handleOnChatListUpdate()
+        }
+    }
+
+    func handleOnChatListUpdate() {
+        if isEditing {
+            isChatListUpdatePending = true
+            return
+        }
+        isChatListUpdatePending = false
+        if let onChatListUpdate = onChatListUpdate {
             if Thread.isMainThread {
                 onChatListUpdate()
             } else {
@@ -146,6 +160,18 @@ class ChatListViewModel: NSObject {
         }
     }
 
+    func chatIdsFor(indexPaths: [IndexPath]?) -> [Int] {
+        guard let indexPaths = indexPaths else { return [] }
+        var chatIds: [Int] = []
+        for indexPath in indexPaths {
+            guard let chatId = chatIdFor(section: indexPath.section, row: indexPath.row) else {
+                continue
+            }
+            chatIds.append(chatId)
+        }
+        return chatIds
+    }
+
     func msgIdFor(row: Int) -> Int? {
         if showSearchResults {
             return nil
@@ -174,6 +200,28 @@ class ChatListViewModel: NSObject {
         return nil
     }
 
+    func deleteChats(indexPaths: [IndexPath]?) {
+        let chatIds = chatIdsFor(indexPaths: indexPaths)
+        for chatId in chatIds {
+            deleteChat(chatId: chatId)
+        }
+    }
+
+    func archiveChatsToggle(indexPaths: [IndexPath]?) {
+        let chatIds = chatIdsFor(indexPaths: indexPaths)
+        for chatId in chatIds {
+            archiveChatToggle(chatId: chatId)
+        }
+    }
+
+    func pinChatsToggle(indexPaths: [IndexPath]?) {
+        let chatIds = chatIdsFor(indexPaths: indexPaths)
+        let onlyPinnedChatsSelected = hasOnlyPinnedChatsSelected(chatIds: chatIds)
+        for chatId in chatIds {
+            pinChat(chatId: chatId, pinned: onlyPinnedChatsSelected)
+        }
+    }
+
     func deleteChat(chatId: Int) {
         dcContext.deleteChat(chatId: chatId)
         NotificationManager.removeNotificationsForChat(dcContext: dcContext, chatId: chatId)
@@ -192,13 +240,46 @@ class ChatListViewModel: NSObject {
 
     func pinChatToggle(chatId: Int) {
         let chat: DcChat = dcContext.getChat(chatId: chatId)
-        let pinned = chat.visibility==DC_CHAT_VISIBILITY_PINNED
+        let pinned = chat.visibility == DC_CHAT_VISIBILITY_PINNED
+        pinChat(chatId: chatId, pinned: pinned)
+    }
+
+    func pinChat(chatId: Int, pinned: Bool) {
         self.dcContext.setChatVisibility(chatId: chatId, visibility: pinned ? DC_CHAT_VISIBILITY_NORMAL : DC_CHAT_VISIBILITY_PINNED)
         updateChatList(notifyListener: false)
     }
-}
 
-private extension ChatListViewModel {
+    func hasOnlyPinnedChatsSelected(in indexPaths: [IndexPath]?) -> Bool {
+        let chatIds = chatIdsFor(indexPaths: indexPaths)
+        return hasOnlyPinnedChatsSelected(chatIds: chatIds)
+    }
+
+    func hasOnlyPinnedChatsSelected(chatIds: [Int]) -> Bool {
+        if chatIds.isEmpty {
+            return false
+        }
+
+        for chatId in chatIds {
+            let chat: DcChat = dcContext.getChat(chatId: chatId)
+            if chat.visibility != DC_CHAT_VISIBILITY_PINNED {
+                return false
+            }
+        }
+        return true
+    }
+
+    func setEditing(_ editing: Bool) {
+        isEditing = editing
+        if !isEditing && isChatListUpdatePending {
+            handleOnChatListUpdate()
+        }
+    }
+
+    func setPendingChatListUpdate() {
+        if isEditing {
+            isChatListUpdatePending = true
+        }
+    }
 
     // MARK: - avatarCellViewModel factory
     func makeChatCellViewModel(index: Int, searchText: String) -> AvatarCellViewModel {
@@ -291,15 +372,7 @@ private extension ChatListViewModel {
             // when search input field empty we show default chatList
             resetSearch()
         }
-        if let onChatListUpdate = onChatListUpdate {
-            if Thread.isMainThread {
-                onChatListUpdate()
-            } else {
-                DispatchQueue.main.async {
-                    onChatListUpdate()
-                }
-            }
-        }
+        handleOnChatListUpdate()
     }
 
     func filterAndUpdateList(searchText: String) {