Browse Source

Merge pull request #1036 from deltachat/multi-select

Multi select
cyBerta 4 years ago
parent
commit
f318a04836

+ 8 - 0
DcCore/DcCore/DC/Wrapper.swift

@@ -266,10 +266,18 @@ public class DcContext {
         dc_delete_msgs(contextPointer, [UInt32(msgId)], 1)
     }
 
+    public func deleteMessages(msgIds: [Int]) {
+        dc_delete_msgs(contextPointer, msgIds.compactMap { UInt32($0) }, Int32(msgIds.count))
+    }
+
     public func forwardMessage(with msgId: Int, to chat: Int) {
         dc_forward_msgs(contextPointer, [UInt32(msgId)], 1, UInt32(chat))
     }
 
+    public func forwardMessages(with msgIds: [Int], to chat: Int) {
+        dc_forward_msgs(contextPointer, msgIds.compactMap { UInt32($0) }, Int32(msgIds.count), UInt32(chat))
+    }
+
     public func sendTextInChat(id: Int, message: String) {
         dc_send_text_msg(contextPointer, UInt32(id), message)
     }

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

@@ -55,6 +55,7 @@
 		30653081254358B10093E196 /* QuoteView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30653080254358B10093E196 /* QuoteView.swift */; };
 		306C32322445CDE9001D89F3 /* DcLogger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 306C32312445CDE9001D89F3 /* DcLogger.swift */; };
 		30734326249A280B00BF9AD1 /* MediaQualityController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30734325249A280B00BF9AD1 /* MediaQualityController.swift */; };
+		307A82CC25B8D26700748B57 /* ChatEditingBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 307A82CB25B8D26700748B57 /* ChatEditingBar.swift */; };
 		307D822E241669C7006D2490 /* LocationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 307D822D241669C7006D2490 /* LocationManager.swift */; };
 		3095A351237DD1F700AB07F7 /* MediaPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3095A350237DD1F700AB07F7 /* MediaPicker.swift */; };
 		30A4149724F6EFBE00EC91EB /* InfoMessageCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30A4149624F6EFBE00EC91EB /* InfoMessageCell.swift */; };
@@ -291,6 +292,7 @@
 		30653080254358B10093E196 /* QuoteView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuoteView.swift; sourceTree = "<group>"; };
 		306C32312445CDE9001D89F3 /* DcLogger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DcLogger.swift; sourceTree = "<group>"; };
 		30734325249A280B00BF9AD1 /* MediaQualityController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaQualityController.swift; sourceTree = "<group>"; };
+		307A82CB25B8D26700748B57 /* ChatEditingBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatEditingBar.swift; sourceTree = "<group>"; };
 		307D822D241669C7006D2490 /* LocationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationManager.swift; sourceTree = "<group>"; };
 		3095A350237DD1F700AB07F7 /* MediaPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPicker.swift; sourceTree = "<group>"; };
 		30A4149624F6EFBE00EC91EB /* InfoMessageCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InfoMessageCell.swift; sourceTree = "<group>"; };
@@ -558,6 +560,7 @@
 				30FDB6B624D193DD0066C48D /* Cells */,
 				30E348DE24F3F819005C93D1 /* ChatTableView.swift */,
 				303492CE2587C2DC00A523D0 /* ChatInputBar.swift */,
+				307A82CB25B8D26700748B57 /* ChatEditingBar.swift */,
 				302E1BB3252B5AB4008F4264 /* PlayButtonView.swift */,
 				30F8817524DA97DA0023780E /* BackgroundContainer.swift */,
 				3008CB7324F9436C00E6A617 /* AudioPlayerView.swift */,
@@ -1236,6 +1239,7 @@
 				AEE700252438E0E500D6992E /* ProgressAlertHandler.swift in Sources */,
 				30E348E524F6647D005C93D1 /* FileTextCell.swift in Sources */,
 				306C32322445CDE9001D89F3 /* DcLogger.swift in Sources */,
+				307A82CC25B8D26700748B57 /* ChatEditingBar.swift in Sources */,
 				303492952565AABC00A523D0 /* DraftModel.swift in Sources */,
 				78E45E3A21D3CFBC00D4B15E /* SettingsController.swift in Sources */,
 				AE8519EA2272FDCA00ED86F0 /* DeviceContactsHandler.swift in Sources */,

+ 0 - 20
deltachat-ios/Assets.xcassets/ic_close_18pt.imageset/Contents.json

@@ -1,20 +0,0 @@
-{
-  "images" : [
-    {
-      "idiom" : "universal",
-      "scale" : "1x"
-    },
-    {
-      "idiom" : "universal",
-      "scale" : "2x"
-    },
-    {
-      "idiom" : "universal",
-      "scale" : "3x"
-    }
-  ],
-  "info" : {
-    "version" : 1,
-    "author" : "xcode"
-  }
-}

+ 0 - 20
deltachat-ios/Assets.xcassets/ic_close_18pt_3x.imageset/Contents.json

@@ -1,20 +0,0 @@
-{
-  "images" : [
-    {
-      "idiom" : "universal",
-      "scale" : "1x"
-    },
-    {
-      "idiom" : "universal",
-      "scale" : "2x"
-    },
-    {
-      "idiom" : "universal",
-      "scale" : "3x"
-    }
-  ],
-  "info" : {
-    "version" : 1,
-    "author" : "xcode"
-  }
-}

+ 0 - 26
deltachat-ios/Assets.xcassets/ic_content_copy_white_36pt.imageset/Contents.json

@@ -1,26 +0,0 @@
-{
-  "images" : [
-    {
-      "filename" : "baseline_content_copy_white_36pt_1x.png",
-      "idiom" : "universal",
-      "scale" : "1x"
-    },
-    {
-      "filename" : "baseline_content_copy_white_36pt_2x.png",
-      "idiom" : "universal",
-      "scale" : "2x"
-    },
-    {
-      "filename" : "baseline_content_copy_white_36pt_3x.png",
-      "idiom" : "universal",
-      "scale" : "3x"
-    }
-  ],
-  "info" : {
-    "author" : "xcode",
-    "version" : 1
-  },
-  "properties" : {
-    "template-rendering-intent" : "template"
-  }
-}

BIN
deltachat-ios/Assets.xcassets/ic_content_copy_white_36pt.imageset/baseline_content_copy_white_36pt_1x.png


BIN
deltachat-ios/Assets.xcassets/ic_content_copy_white_36pt.imageset/baseline_content_copy_white_36pt_2x.png


BIN
deltachat-ios/Assets.xcassets/ic_content_copy_white_36pt.imageset/baseline_content_copy_white_36pt_3x.png


+ 154 - 27
deltachat-ios/Chat/ChatViewController.swift

@@ -39,6 +39,13 @@ class ChatViewController: UITableViewController {
         return view
     }()
 
+    public lazy var editingBar: ChatEditingBar = {
+        let view = ChatEditingBar()
+        view.delegate = self
+        view.translatesAutoresizingMaskIntoConstraints = false
+        return view
+    }()
+
     open override var shouldAutorotate: Bool {
         return false
     }
@@ -96,7 +103,7 @@ class ChatViewController: UITableViewController {
     private lazy var contextMenu: ContextMenuProvider = {
         let copyItem = ContextMenuProvider.ContextMenuItem(
         title: String.localized("global_menu_edit_copy_desktop"),
-        imageName: "ic_content_copy_white_36pt",
+        imageName: "doc.on.doc",
         action: #selector(BaseMessageCell.messageCopy),
         onPerform: { [weak self] indexPath in
                 guard let self = self else { return }
@@ -164,11 +171,25 @@ class ChatViewController: UITableViewController {
             }
         )
 
+        let selectMoreItem = ContextMenuProvider.ContextMenuItem(
+            title: String.localized("select_more"),
+            imageName: "checkmark.circle",
+            action: #selector(BaseMessageCell.messageSelectMore),
+            onPerform: { indexPath in
+                DispatchQueue.main.async { [weak self] in
+                    guard let self = self else { return }
+                    let messageId = self.messageIds[indexPath.row]
+                    self.setEditing(isEditing: true, selectedAtIndexPath: indexPath)
+                }
+            }
+        )
+
         let config = ContextMenuProvider()
         if #available(iOS 13.0, *), !disableWriting {
-            config.setMenu([replyItem, forwardItem, infoItem, copyItem, deleteItem])
+            let mainContextMenu = ContextMenuProvider.ContextMenuItem(submenuitems: [replyItem, forwardItem, infoItem, copyItem, deleteItem])
+            config.setMenu([mainContextMenu, selectMoreItem])
         } else {
-            config.setMenu([forwardItem, infoItem, copyItem, deleteItem])
+            config.setMenu([forwardItem, infoItem, copyItem, deleteItem, selectMoreItem])
         }
 
         return config
@@ -226,6 +247,7 @@ class ChatViewController: UITableViewController {
         tableView.rowHeight = UITableView.automaticDimension
         tableView.separatorStyle = .none
         tableView.keyboardDismissMode = .interactive
+
         if !dcContext.isConfigured() {
             // TODO: display message about nothing being configured
             return
@@ -237,6 +259,8 @@ class ChatViewController: UITableViewController {
             draft.parse(draftMsg: dcContext.getDraft(chatId: chatId))
             messageInputBar.inputTextView.text = draft.text
             configureDraftArea(draft: draft)
+            editingBar.delegate = self
+            tableView.allowsMultipleSelectionDuringEditing = true
         }
 
         let notificationCenter = NotificationCenter.default
@@ -266,7 +290,7 @@ class ChatViewController: UITableViewController {
             DispatchQueue.main.async {
                 guard let self = self else { return }
                 self.messageIds = self.getMessageIds()
-                self.tableView.reloadData()
+                self.reloadData()
             }
         }
     }
@@ -409,7 +433,7 @@ class ChatViewController: UITableViewController {
                 self.updateTitle(chat: self.dcContext.getChat(chatId: self.chatId))
                 if lastSectionVisibleBeforeTransition {
                     DispatchQueue.main.async { [weak self] in
-                        self?.tableView.reloadData()
+                        self?.reloadData()
                         self?.scrollToBottom(animated: false)
                     }
                 }
@@ -425,7 +449,7 @@ class ChatViewController: UITableViewController {
     }
 
     override func tableView(_: UITableView, numberOfRowsInSection section: Int) -> Int {
-        return messageIds.count //viewModel.numberOfRowsIn(section: section)
+        return messageIds.count
     }
 
     override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
@@ -464,6 +488,7 @@ class ChatViewController: UITableViewController {
                     messageStyle: configureMessageStyle(for: message, at: indexPath),
                     isAvatarVisible: configureAvatarVisibility(for: message, at: indexPath),
                     isGroup: isGroupChat)
+
         return cell
     }
 
@@ -479,7 +504,17 @@ class ChatViewController: UITableViewController {
 
     private func configureDraftArea(draft: DraftModel) {
         draftArea.configure(draft: draft)
-        // setStackViewItems recalculates the proper messageInputBar height
+        if draft.isEditing {
+            messageInputBar.setMiddleContentView(editingBar, animated: false)
+            messageInputBar.setLeftStackViewWidthConstant(to: 0, animated: false)
+            messageInputBar.setRightStackViewWidthConstant(to: 0, animated: false)
+            messageInputBar.padding = UIEdgeInsets(top: 6, left: 0, bottom: 6, right: 0)
+        } else {
+            messageInputBar.setMiddleContentView(messageInputBar.inputTextView, animated: false)
+            messageInputBar.setLeftStackViewWidthConstant(to: 40, animated: false)
+            messageInputBar.setRightStackViewWidthConstant(to: 40, animated: false)
+            messageInputBar.padding = UIEdgeInsets(top: 6, left: 6, bottom: 6, right: 12)
+        }
         messageInputBar.setStackViewItems([draftArea], forStack: .top, animated: true)
     }
 
@@ -524,7 +559,17 @@ class ChatViewController: UITableViewController {
         }
     }
 
+    override func tableView(_ tableView: UITableView, didDeselectRowAt indexPath: IndexPath) {
+        if tableView.isEditing {
+            handleEditingBar()
+        }
+    }
+    
     override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
+        if tableView.isEditing {
+            handleEditingBar()
+            return
+        }
         let messageId = messageIds[indexPath.row]
         let message = DcMsg(id: messageId)
         if message.isSetupMessage {
@@ -601,13 +646,23 @@ class ChatViewController: UITableViewController {
     private func refreshMessages() {
         self.messageIds = self.getMessageIds()
         let wasLastSectionVisible = self.isLastRowVisible()
-        self.tableView.reloadData()
+        self.reloadData()
         if wasLastSectionVisible {
             self.scrollToBottom(animated: true)
         }
         self.showEmptyStateView(self.messageIds.isEmpty)
     }
 
+    func reloadData() {
+        let selectredRows = tableView.indexPathsForSelectedRows
+        tableView.reloadData()
+        // There's an iOS bug, filling up the console output but which can be ignored: https://developer.apple.com/forums/thread/668295
+        // [Assert] Attempted to call -cellForRowAtIndexPath: on the table view while it was in the process of updating its visible cells, which is not allowed.
+        selectredRows?.forEach({ (selectedRow) in
+            tableView.selectRow(at: selectedRow, animated: false, scrollPosition: .none)
+        })
+    }
+
     private func loadMessages() {
         DispatchQueue.global(qos: .userInitiated).async {
             DispatchQueue.main.async { [weak self] in
@@ -616,7 +671,7 @@ class ChatViewController: UITableViewController {
                 let wasMessageIdsEmpty = self.messageIds.isEmpty
                 // update message ids
                 self.messageIds = self.getMessageIds()
-                self.tableView.reloadData()
+                self.reloadData()
                 if let msgId = self.highlightedMsg, let msgPosition = self.messageIds.firstIndex(of: msgId) {
                     self.tableView.scrollToRow(at: IndexPath(row: msgPosition, section: 0), at: .top, animated: false)
                     self.highlightedMsg = nil
@@ -690,13 +745,10 @@ class ChatViewController: UITableViewController {
         messageInputBar.delegate = self
         messageInputBar.inputTextView.tintColor = DcColors.primary
         messageInputBar.inputTextView.placeholder = String.localized("chat_input_placeholder")
-        messageInputBar.separatorLine.isHidden = true
+        messageInputBar.separatorLine.backgroundColor = DcColors.colorDisabled
         messageInputBar.inputTextView.tintColor = DcColors.primary
         messageInputBar.inputTextView.textColor = DcColors.defaultTextColor
         messageInputBar.backgroundView.backgroundColor = DcColors.chatBackgroundColor
-
-        //scrollsToBottomOnKeyboardBeginsEditing = true
-
         messageInputBar.inputTextView.backgroundColor = DcColors.inputFieldColor
         messageInputBar.inputTextView.placeholderTextColor = DcColors.placeholderColor
         messageInputBar.inputTextView.textContainerInset = UIEdgeInsets(top: 8, left: 16, bottom: 8, right: 38)
@@ -837,10 +889,17 @@ class ChatViewController: UITableViewController {
     }
 
     private func askToDeleteMessage(id: Int) {
-        let title = String.localized(stringID: "ask_delete_messages", count: 1)
+        self.askToDeleteMessages(ids: [id])
+    }
+
+    private func askToDeleteMessages(ids: [Int]) {
+        let title = String.localized(stringID: "ask_delete_messages", count: ids.count)
         confirmationAlert(title: title, actionTitle: String.localized("delete"), actionStyle: .destructive,
                           actionHandler: { _ in
-                            self.dcContext.deleteMessage(msgId: id)
+                            self.dcContext.deleteMessages(msgIds: ids)
+                            if self.tableView.isEditing {
+                                self.setEditing(isEditing: false)
+                            }
                           })
     }
 
@@ -958,7 +1017,7 @@ class ChatViewController: UITableViewController {
                 self?.dcContext.markSeenMessages(messageIds: [UInt32(messageId)])
             }
             let wasLastSectionVisible = self.isLastRowVisible()
-            tableView.reloadData()
+            reloadData()
             if wasLastSectionVisible {
                 self.scrollToBottom(animated: true)
             }
@@ -979,7 +1038,7 @@ class ChatViewController: UITableViewController {
         messageIds.append(message.id)
         emptyStateView.isHidden = true
 
-        tableView.reloadData()
+        reloadData()
         if wasLastSectionVisible || message.isFromCurrentSender {
             scrollToBottom(animated: true)
         }
@@ -1083,7 +1142,7 @@ class ChatViewController: UITableViewController {
     }
 
     override func tableView(_ tableView: UITableView, canPerformAction action: Selector, forRowAt indexPath: IndexPath, withSender sender: Any?) -> Bool {
-        return contextMenu.canPerformAction(action: action)
+        return !tableView.isEditing && contextMenu.canPerformAction(action: action)
     }
 
     override func tableView(_ tableView: UITableView, performAction action: Selector, forRowAt indexPath: IndexPath, withSender sender: Any?) {
@@ -1094,6 +1153,9 @@ class ChatViewController: UITableViewController {
     // context menu for iOS 13+
     @available(iOS 13, *)
     override func tableView(_ tableView: UITableView, contextMenuConfigurationForRowAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? {
+        if tableView.isEditing {
+            return nil
+        }
         return UIContextMenuConfiguration(
             identifier: nil,
             previewProvider: nil,
@@ -1162,12 +1224,44 @@ class ChatViewController: UITableViewController {
         }
         return false
     }
+
+    func handleSelection(indexPath: IndexPath) -> Bool {
+        if tableView.isEditing {
+            if tableView.indexPathsForSelectedRows?.contains(indexPath) ?? false {
+                tableView.deselectRow(at: indexPath, animated: false)
+            } else {
+                tableView.selectRow(at: indexPath, animated: false, scrollPosition: .none)
+            }
+            handleEditingBar()
+            return true
+        }
+        return false
+    }
+
+    func handleEditingBar() {
+        if let rows = tableView.indexPathsForSelectedRows,
+           !rows.isEmpty {
+            editingBar.isEnabled = true
+        } else {
+            editingBar.isEnabled = false
+        }
+    }
+
+    func setEditing(isEditing: Bool, selectedAtIndexPath: IndexPath? = nil) {
+        self.tableView.setEditing(isEditing, animated: true)
+        self.draft.isEditing = isEditing
+        self.configureDraftArea(draft: self.draft)
+        if let indexPath = selectedAtIndexPath {
+            _ = handleSelection(indexPath: indexPath)
+        }
+    }
 }
 
 // MARK: - BaseMessageCellDelegate
 extension ChatViewController: BaseMessageCellDelegate {
 
     @objc func quoteTapped(indexPath: IndexPath) {
+        if handleSelection(indexPath: indexPath) { return }
         _ = handleUIMenu()
         let msg = DcMsg(id: messageIds[indexPath.row])
         if let quoteMsg = msg.quoteMessage {
@@ -1176,37 +1270,47 @@ extension ChatViewController: BaseMessageCellDelegate {
     }
 
     @objc func textTapped(indexPath: IndexPath) {
-        if handleUIMenu() { return }
+        if handleUIMenu() || handleSelection(indexPath: indexPath) {
+            return
+        }
+
         let message = DcMsg(id: messageIds[indexPath.row])
         if message.isSetupMessage {
             didTapAsm(msg: message, orgText: "")
         }
     }
 
-    @objc func phoneNumberTapped(number: String) {
-        if handleUIMenu() { return }
+    @objc func phoneNumberTapped(number: String, indexPath: IndexPath) {
+        if handleUIMenu() || handleSelection(indexPath: indexPath) {
+            return
+        }
         logger.debug("phone number tapped \(number)")
     }
 
-    @objc func commandTapped(command: String) {
-        if handleUIMenu() { return }
+    @objc func commandTapped(command: String, indexPath: IndexPath) {
+        if handleUIMenu() || handleSelection(indexPath: indexPath) {
+            return
+        }
         logger.debug("command tapped \(command)")
     }
 
-    @objc func urlTapped(url: URL) {
-        if handleUIMenu() { return }
+    @objc func urlTapped(url: URL, indexPath: IndexPath) {
+        if handleUIMenu() || handleSelection(indexPath: indexPath) {
+            return
+        }
         if Utils.isEmail(url: url) {
             logger.debug("tapped on contact")
             let email = Utils.getEmailFrom(url)
             self.askToChatWith(email: email)
-            ///TODO: implement handling
         } else {
             UIApplication.shared.open(url)
         }
     }
 
     @objc func imageTapped(indexPath: IndexPath) {
-        if handleUIMenu() { return }
+        if handleUIMenu() || handleSelection(indexPath: indexPath) {
+            return
+        }
         showMediaGalleryFor(indexPath: indexPath)
     }
 
@@ -1302,6 +1406,29 @@ extension ChatViewController: DraftPreviewDelegate {
     }
 }
 
+// MARK: - ChatEditingDelegate
+extension ChatViewController: ChatEditingDelegate {
+    func onDeletePressed() {
+        if let rows = tableView.indexPathsForSelectedRows {
+            let messageIdsToDelete = rows.compactMap { messageIds[$0.row] }
+            askToDeleteMessages(ids: messageIdsToDelete)
+        }
+    }
+
+    func onForwardPressed() {
+        if let rows = tableView.indexPathsForSelectedRows {
+            let messageIdsToForward = rows.compactMap { messageIds[$0.row] }
+            RelayHelper.sharedInstance.setForwardMessages(messageIds: messageIdsToForward)
+            self.navigationController?.popViewController(animated: true)
+        }
+    }
+
+    func onCancelPressed() {
+        setEditing(isEditing: false)
+    }
+}
+
+// MARK: - QLPreviewControllerDelegate
 extension ChatViewController: QLPreviewControllerDelegate {
     @available(iOS 13.0, *)
     func previewController(_ controller: QLPreviewController, editingModeFor previewItem: QLPreviewItem) -> QLPreviewItemEditingMode {

+ 1 - 0
deltachat-ios/Chat/DraftModel.swift

@@ -10,6 +10,7 @@ public class DraftModel {
     var attachmentMimeType: String?
     var viewType: Int32?
     let chatId: Int
+    var isEditing: Bool = false
 
     public init(chatId: Int) {
         self.chatId = chatId

+ 17 - 7
deltachat-ios/Chat/Views/Cells/BaseMessageCell.swift

@@ -146,6 +146,7 @@ public class BaseMessageCell: UITableViewCell {
 
 
     func setupSubviews() {
+        selectedBackgroundView = UIView()
         contentView.addSubview(messageBackgroundContainer)
         messageBackgroundContainer.addSubview(mainContentView)
         messageBackgroundContainer.addSubview(topLabel)
@@ -185,7 +186,7 @@ public class BaseMessageCell: UITableViewCell {
 
         topCompactView = false
         bottomCompactView = false
-        selectionStyle = .none
+        
 
         let gestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(onAvatarTapped))
         gestureRecognizer.numberOfTapsRequired = 1
@@ -417,6 +418,10 @@ public class BaseMessageCell: UITableViewCell {
         self.performAction(#selector(BaseMessageCell.messageCopy(_:)), with: sender)
     }
 
+    @objc func messageSelectMore(_ sender: Any?) {
+        self.performAction(#selector(BaseMessageCell.messageSelectMore(_:)), with: sender)
+    }
+
     func performAction(_ action: Selector, with sender: Any?) {
         if let tableView = self.superview as? UITableView, let indexPath = tableView.indexPath(for: self) {
             // Trigger action in tableView delegate (UITableViewController)
@@ -434,12 +439,17 @@ extension BaseMessageCell: MessageLabelDelegate {
     public func didSelectDate(_ date: Date) {}
 
     public func didSelectPhoneNumber(_ phoneNumber: String) {
-        baseDelegate?.phoneNumberTapped(number: phoneNumber)
+        if let tableView = self.superview as? UITableView, let indexPath = tableView.indexPath(for: self) {
+            baseDelegate?.phoneNumberTapped(number: phoneNumber, indexPath: indexPath)
+        }
+
     }
 
     public func didSelectURL(_ url: URL) {
-        logger.debug("did select URL")
-        baseDelegate?.urlTapped(url: url)
+        if let tableView = self.superview as? UITableView, let indexPath = tableView.indexPath(for: self) {
+            logger.debug("did select URL")
+            baseDelegate?.urlTapped(url: url, indexPath: indexPath)
+        }
     }
 
     public func didSelectTransitInformation(_ transitInformation: [String: String]) {}
@@ -454,9 +464,9 @@ extension BaseMessageCell: MessageLabelDelegate {
 // MARK: - BaseMessageCellDelegate
 // this delegate contains possible events from base cells or from derived cells
 public protocol BaseMessageCellDelegate: class {
-    func commandTapped(command: String) // `/command`
-    func phoneNumberTapped(number: String)
-    func urlTapped(url: URL) // url is eg. `https://foo.bar`
+    func commandTapped(command: String, indexPath: IndexPath) // `/command`
+    func phoneNumberTapped(number: String, indexPath: IndexPath)
+    func urlTapped(url: URL, indexPath: IndexPath) // url is eg. `https://foo.bar`
     func imageTapped(indexPath: IndexPath)
     func avatarTapped(indexPath: IndexPath)
     func textTapped(indexPath: IndexPath)

+ 125 - 0
deltachat-ios/Chat/Views/ChatEditingBar.swift

@@ -0,0 +1,125 @@
+import UIKit
+import InputBarAccessoryView
+import DcCore
+
+public protocol ChatEditingDelegate: class {
+    func onDeletePressed()
+    func onForwardPressed()
+    func onCancelPressed()
+}
+
+public class ChatEditingBar: UIView, InputItem {
+    public var inputBarAccessoryView: InputBarAccessoryView?
+    public var parentStackViewPosition: InputStackView.Position?
+    public func textViewDidChangeAction(with textView: InputTextView) {}
+    public func keyboardSwipeGestureAction(with gesture: UISwipeGestureRecognizer) {}
+    public func keyboardEditingEndsAction() {}
+    public func keyboardEditingBeginsAction() {}
+
+
+    public var isEnabled: Bool {
+        willSet(newValue) {
+            deleteButton.isEnabled = newValue
+            forwardButton.isEnabled = newValue
+        }
+    }
+
+    weak var delegate: ChatEditingDelegate?
+
+    private lazy var cancelButton: UIButton = {
+        let view = UIButton()
+        view.setTitle(String.localized("cancel"), for: .normal)
+        view.setTitleColor(.systemBlue, for: .normal)
+        view.translatesAutoresizingMaskIntoConstraints = false
+        view.imageView?.contentMode = .scaleAspectFit
+        view.isUserInteractionEnabled = true
+        return view
+    }()
+
+    private lazy var deleteButton: UIButton = {
+        let view = UIButton()
+
+        if #available(iOS 13.0, *) {
+            view.setImage(UIImage(systemName: "trash"), for: .normal)
+            view.tintColor = .systemBlue
+        } else {
+            view.setTitle(String.localized("delete"), for: .normal)
+            view.setTitleColor(.systemBlue, for: .normal)
+        }
+        view.translatesAutoresizingMaskIntoConstraints = false
+        view.isUserInteractionEnabled = true
+        view.imageView?.contentMode = .scaleAspectFit
+        return view
+    }()
+
+    private lazy var forwardButton: UIButton = {
+        let view = UIButton()
+        view.tintColor = .systemBlue
+        view.setImage( #imageLiteral(resourceName: "ic_forward_white_36pt").withRenderingMode(.alwaysTemplate), for: .normal)
+        view.translatesAutoresizingMaskIntoConstraints = false
+        view.imageView?.contentMode = .scaleAspectFit
+        view.isUserInteractionEnabled = true
+        return view
+    }()
+
+    private lazy var mainContentView: UIStackView = {
+        let view = UIStackView(arrangedSubviews: [cancelButton, forwardButton, deleteButton])
+        view.axis = .horizontal
+        view.distribution = .fillEqually
+        view.alignment = .center
+        view.translatesAutoresizingMaskIntoConstraints = false
+        return view
+    }()
+
+    convenience init() {
+        self.init(frame: .zero)
+
+    }
+
+    public override init(frame: CGRect) {
+        isEnabled = false
+        super.init(frame: frame)
+        self.setupSubviews()
+    }
+
+    required init(coder: NSCoder) {
+        fatalError("init(coder:) has not been implemented")
+    }
+
+    public func setupSubviews() {
+        addSubview(mainContentView)
+
+        addConstraints([
+            mainContentView.constraintAlignTopTo(self, paddingTop: 4),
+            mainContentView.constraintAlignBottomTo(self, paddingBottom: 4),
+            mainContentView.constraintAlignLeadingTo(self),
+            mainContentView.constraintAlignTrailingTo(self),
+            deleteButton.constraintHeightTo(36),
+            forwardButton.constraintHeightTo(26),
+            cancelButton.constraintHeightTo(36),
+        ])
+
+        backgroundColor = DcColors.chatBackgroundColor
+
+        let cancelGestureListener = UITapGestureRecognizer(target: self, action: #selector(onCancelPressed))
+        cancelButton.addGestureRecognizer(cancelGestureListener)
+
+        let forwardGestureListener = UITapGestureRecognizer(target: self, action: #selector(onForwardPressed))
+        forwardButton.addGestureRecognizer(forwardGestureListener)
+
+        let deleteGestureListener = UITapGestureRecognizer(target: self, action: #selector(onDeletePressed))
+        deleteButton.addGestureRecognizer(deleteGestureListener)
+    }
+
+    @objc func onCancelPressed() {
+        delegate?.onCancelPressed()
+    }
+
+    @objc func onForwardPressed() {
+        delegate?.onForwardPressed()
+    }
+
+    @objc func onDeletePressed() {
+        delegate?.onDeletePressed()
+    }
+}

+ 6 - 2
deltachat-ios/Chat/Views/ChatInputBar.swift

@@ -1,5 +1,6 @@
 import UIKit
 import InputBarAccessoryView
+import DcCore
 
 
 public class ChatInputBar: InputBarAccessoryView {
@@ -20,6 +21,7 @@ public class ChatInputBar: InputBarAccessoryView {
     required public init?(coder aDecoder: NSCoder) {
         super.init(coder: aDecoder)
         setupKeyboardObserver()
+        backgroundColor = DcColors.chatBackgroundColor
     }
 
     func setupKeyboardObserver() {
@@ -56,8 +58,10 @@ public class ChatInputBar: InputBarAccessoryView {
     }
 
     public func configure(draft: DraftModel) {
-        hasDraft = draft.attachment != nil
-        hasQuote = draft.quoteText != nil
+        hasDraft = !draft.isEditing && draft.attachment != nil
+        hasQuote = !draft.isEditing && draft.quoteText != nil
+        leftStackView.isHidden = draft.isEditing
+        rightStackView.isHidden = draft.isEditing
         maxTextViewHeight = calculateMaxTextViewHeight()
     }
 

+ 3 - 1
deltachat-ios/Chat/Views/DocumentPreview.swift

@@ -34,7 +34,9 @@ public class DocumentPreview: DraftPreview {
     }
 
     override public func configure(draft: DraftModel) {
-        if draft.viewType == DC_MSG_FILE, let path = draft.attachment {
+        if !draft.isEditing,
+           draft.viewType == DC_MSG_FILE,
+           let path = draft.attachment {
             let tmpMsg = DcMsg(viewType: DC_MSG_FILE)
             tmpMsg.setFile(filepath: path)
             tmpMsg.text = draft.text

+ 1 - 1
deltachat-ios/Chat/Views/DraftPreview.swift

@@ -58,7 +58,7 @@ public class DraftPreview: UIView {
             upperBorder.constraintAlignLeadingTo(self),
             upperBorder.constraintAlignTrailingTo(self),
             upperBorder.constraintHeightTo(1),
-            upperBorder.constraintAlignTopTo(self, paddingTop: 4),
+            upperBorder.constraintAlignTopTo(self),
             mainContentView.constraintAlignTopTo(upperBorder, paddingTop: 4),
             mainContentView.constraintAlignLeadingTo(self),
             mainContentView.constraintAlignBottomTo(self, paddingBottom: 4),

+ 5 - 0
deltachat-ios/Chat/Views/MediaPreview.swift

@@ -31,6 +31,11 @@ class MediaPreview: DraftPreview {
     }
 
     override func configure(draft: DraftModel) {
+        if draft.isEditing {
+            self.isHidden = true
+            return
+        }
+        
         if (draft.viewType == DC_MSG_GIF || draft.viewType == DC_MSG_IMAGE), let path = draft.attachment {
             contentImageView.sd_setImage(with: URL(fileURLWithPath: path, isDirectory: false), completed: { image, error, _, _ in
                 if let error = error {

+ 2 - 1
deltachat-ios/Chat/Views/QuotePreview.swift

@@ -26,7 +26,8 @@ public class QuotePreview: DraftPreview {
     }
 
     override public func configure(draft: DraftModel) {
-        if let quoteText = draft.quoteText {
+        if !draft.isEditing,
+           let quoteText = draft.quoteText {
             quoteView.quote.text = quoteText
             compactView = draft.attachment != nil
             calculateQuoteHeight(compactView: compactView)

+ 44 - 16
deltachat-ios/Controller/ContextMenuController.swift

@@ -132,7 +132,9 @@ class ContextMenuProvider {
 
     // iOS 12- action menu
     var menuItems: [UIMenuItem] {
-        return menu.map { UIMenuItem(title: $0.title, action: $0.action) }
+        return menu
+            .filter({ $0.title != nil && $0.action != nil })
+            .map({ return UIMenuItem(title: $0.title!, action: $0.action!) })
     }
 
     // iOS13+ action menu
@@ -142,18 +144,17 @@ class ContextMenuProvider {
         var children: [UIMenuElement] = []
 
         for item in menu {
-            let image = UIImage(systemName: item.imageName) ??
-                UIImage(named: item.imageName)
-
-            let action = UIAction(
-                title: item.title,
-                image: image,
-                handler: { _ in item.onPerform?(indexPath) }
-            )
-            if item.isDestructive {
-                action.attributes = [.destructive]
+            //we only support 1 submenu layer for now
+            if let subMenus = item.children {
+                var submenuChildren: [UIMenuElement] = []
+                for submenuItem in subMenus {
+                    submenuChildren.append(generateUIAction(item: submenuItem, indexPath: indexPath))
+                }
+                let submenu = UIMenu(title: "", options: .displayInline, children: submenuChildren)
+                children.append(submenu)
+            } else {
+                children.append(generateUIAction(item: item, indexPath: indexPath))
             }
-            children.append(action)
         }
 
         return UIMenu(
@@ -164,6 +165,23 @@ class ContextMenuProvider {
         )
     }
 
+    @available(iOS 13, *)
+    private func generateUIAction(item: ContextMenuItem, indexPath: IndexPath) -> UIAction {
+        let image = UIImage(systemName: item.imageName ?? "") ??
+            UIImage(named: item.imageName ?? "")
+
+        let action = UIAction(
+            title: item.title ?? "",
+            image: image,
+            handler: { _ in item.onPerform?(indexPath) }
+        )
+        if item.isDestructive ?? false {
+            action.attributes = [.destructive]
+        }
+
+        return action
+    }
+
     func canPerformAction(action: Selector) -> Bool {
         return !menu.filter {
             $0.action == action
@@ -180,11 +198,12 @@ class ContextMenuProvider {
 
 extension ContextMenuProvider {
     struct ContextMenuItem {
-        var title: String
-        var imageName: String
-        let isDestructive: Bool
-        var action: Selector
+        var title: String?
+        var imageName: String?
+        let isDestructive: Bool?
+        var action: Selector?
         var onPerform: ((IndexPath) -> Void)?
+        var children: [ContextMenuItem]?
 
         init(title: String, imageName: String, isDestructive: Bool = false, action: Selector, onPerform: ((IndexPath) -> Void)?) {
             self.title = title
@@ -193,5 +212,14 @@ extension ContextMenuProvider {
             self.action = action
             self.onPerform = onPerform
         }
+
+        init(submenuitems: [ContextMenuItem]) {
+            title = nil
+            imageName = nil
+            isDestructive = nil
+            action = nil
+            onPerform = nil
+            children = submenuitems
+        }
     }
 }

+ 4 - 3
deltachat-ios/Controller/MailboxViewController.swift

@@ -6,6 +6,7 @@ class MailboxViewController: ChatViewController {
     override init(dcContext: DcContext, chatId: Int, highlightedMsg: Int? = nil) {
         super.init(dcContext: dcContext, chatId: chatId)
         hidesBottomBarWhenPushed = true
+        tableView.allowsSelectionDuringEditing = false
         showCustomNavBar = false
     }
 
@@ -22,9 +23,9 @@ class MailboxViewController: ChatViewController {
         askToChat(messageId: messageIds[indexPath.row])
     }
 
-    override func phoneNumberTapped(number: String) {}
-    override func commandTapped(command: String) {}
-    override func urlTapped(url: URL) {}
+    override func phoneNumberTapped(number: String, indexPath: IndexPath) {}
+    override func commandTapped(command: String, indexPath: IndexPath) {}
+    override func urlTapped(url: URL, indexPath: IndexPath) {}
     override func imageTapped(indexPath: IndexPath) {
         askToChat(messageId: messageIds[indexPath.row])
     }

+ 11 - 7
deltachat-ios/Helper/RelayHelper.swift

@@ -4,7 +4,7 @@ import DcCore
 class RelayHelper {
     static var sharedInstance: RelayHelper = RelayHelper()
     private static var dcContext: DcContext?
-    var messageId: Int?
+    var messageIds: [Int]?
 
     private init() {
         guard RelayHelper.dcContext != nil else {
@@ -17,21 +17,25 @@ class RelayHelper {
     }
 
     func setForwardMessage(messageId: Int) {
-        self.messageId = messageId
+        self.messageIds = [messageId]
+    }
+
+    func setForwardMessages(messageIds: [Int]) {
+        self.messageIds = messageIds
     }
 
     func isForwarding() -> Bool {
-        return messageId != nil
+        return !(messageIds?.isEmpty ?? true)
     }
 
     func forward(to chat: Int) {
-        if let messageId = self.messageId {
-            RelayHelper.dcContext?.forwardMessage(with: messageId, to: chat)
+        if let messageIds = self.messageIds {
+            RelayHelper.dcContext?.forwardMessages(with: messageIds, to: chat)
         }
-        self.messageId = nil
+        self.messageIds = nil
     }
 
     func cancel() {
-        messageId = nil
+        messageIds = nil
     }
 }