浏览代码

Merge pull request #995 from deltachat/swipe-to-reply

Swipe-to-reply
nayooti 4 年之前
父节点
当前提交
939d8ebf03

+ 14 - 22
DcCore/DcCore/DC/Wrapper.swift

@@ -232,27 +232,15 @@ public class DcContext {
         dc_set_stock_translation(contextPointer, UInt32(id), String.localized(localizationKey))
     }
 
-    public func getDraft(chatId: Int) -> String? {
+    public func getDraft(chatId: Int) -> DcMsg? {
         if let draft = dc_get_draft(contextPointer, UInt32(chatId)) {
-            if let cString = dc_msg_get_text(draft) {
-                let swiftString = String(cString: cString)
-                dc_str_unref(cString)
-                dc_msg_unref(draft)
-                return swiftString
-            }
-            dc_msg_unref(draft)
-            return nil
+            return DcMsg(pointer: draft)
         }
         return nil
     }
 
-    public func setDraft(chatId: Int, draftText: String) {
-        let draft = dc_msg_new(contextPointer, DC_MSG_TEXT)
-        dc_msg_set_text(draft, draftText.cString(using: .utf8))
-        dc_set_draft(contextPointer, UInt32(chatId), draft)
-
-        // cleanup
-        dc_msg_unref(draft)
+    public func setDraft(chatId: Int, message: DcMsg?) {
+        dc_set_draft(contextPointer, UInt32(chatId), message?.messagePointer)
     }
 
     public func getFreshMessages() -> DcArray {
@@ -793,10 +781,6 @@ public class DcMsg {
         messagePointer = dc_get_msg(DcContext.shared.contextPointer, UInt32(id))
     }
 
-    public init(type: Int32) {
-        messagePointer = dc_msg_new(DcContext.shared.contextPointer, type)
-    }
-
     init(pointer: OpaquePointer) {
         messagePointer = pointer
     }
@@ -869,8 +853,16 @@ public class DcMsg {
     }
 
     public var quoteMessage: DcMsg? {
-        guard let msgpointer = dc_msg_get_quoted_msg(messagePointer) else { return nil }
-        return DcMsg(pointer: msgpointer)
+        get {
+            guard let msgpointer = dc_msg_get_quoted_msg(messagePointer) else { return nil }
+            return DcMsg(pointer: msgpointer)
+        }
+        set {
+            if newValue == nil {
+                fatalError("Quote message cannot be set to null!")
+            }
+            dc_msg_set_quote(messagePointer, newValue?.messagePointer)
+        }
     }
 
     public var viewtype: MessageViewType? {

+ 19 - 0
DcCore/DcCore/Extensions/UIView+Extensions.swift

@@ -64,6 +64,25 @@ public extension UIView {
         }
         return constraint
     }
+    
+    /**
+     ensure the top of self is aligned with or lower than another view
+     can be used in conjunction with constraintAlignCenterY
+     */
+    func constraintAlignTopMaxTo(_ view: UIView, paddingTop: CGFloat = 0.0, priority: UILayoutPriority? = .none) -> NSLayoutConstraint {
+        let constraint = NSLayoutConstraint(
+            item: self,
+            attribute: .top,
+            relatedBy: .greaterThanOrEqual,
+            toItem: view,
+            attribute: .top,
+            multiplier: 1.0,
+            constant: paddingTop)
+        if let priority = priority {
+            constraint.priority = priority
+        }
+        return constraint
+    }
 
     func constraintAlignBottomTo(_ view: UIView, paddingBottom: CGFloat = 0.0, priority: UILayoutPriority? = .none) -> NSLayoutConstraint {
         let constraint = NSLayoutConstraint(

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

@@ -19,6 +19,8 @@
 		302B84C72396770B001C261F /* RelayHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 302B84C42396627F001C261F /* RelayHelper.swift */; };
 		302B84CE2397F6CD001C261F /* URL+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 302B84CD2397F6CD001C261F /* URL+Extension.swift */; };
 		302E1BB4252B5AB4008F4264 /* PlayButtonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 302E1BB3252B5AB4008F4264 /* PlayButtonView.swift */; };
+		30349291256441E200A523D0 /* QuotePreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30349290256441E200A523D0 /* QuotePreview.swift */; };
+		303492952565AABC00A523D0 /* DraftModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 303492942565AABC00A523D0 /* DraftModel.swift */; };
 		304219D3243F588500516852 /* DcCore.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 304219D1243F588500516852 /* DcCore.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
 		304219D92440734A00516852 /* DcMsg+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 304219D82440734A00516852 /* DcMsg+Extension.swift */; };
 		304F5E44244F571C00462538 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 7A9FB14A1FB061E2001FEA36 /* Assets.xcassets */; };
@@ -213,6 +215,8 @@
 		302B84C42396627F001C261F /* RelayHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelayHelper.swift; sourceTree = "<group>"; };
 		302B84CD2397F6CD001C261F /* URL+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "URL+Extension.swift"; sourceTree = "<group>"; };
 		302E1BB3252B5AB4008F4264 /* PlayButtonView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = PlayButtonView.swift; path = "deltachat-ios/Chat/Views/PlayButtonView.swift"; sourceTree = SOURCE_ROOT; };
+		30349290256441E200A523D0 /* QuotePreview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuotePreview.swift; sourceTree = "<group>"; };
+		303492942565AABC00A523D0 /* DraftModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DraftModel.swift; sourceTree = "<group>"; };
 		304219D1243F588500516852 /* DcCore.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = DcCore.framework; sourceTree = BUILT_PRODUCTS_DIR; };
 		304219D82440734A00516852 /* DcMsg+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DcMsg+Extension.swift"; sourceTree = "<group>"; };
 		3052C609253F082E007D13EA /* MessageLabelDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageLabelDelegate.swift; sourceTree = "<group>"; };
@@ -525,6 +529,7 @@
 				3008CB7524F95B6D00E6A617 /* AudioController.swift */,
 				30FDB6F824D1C1000066C48D /* ChatViewController.swift */,
 				30FDB6B524D193DD0066C48D /* Views */,
+				303492942565AABC00A523D0 /* DraftModel.swift */,
 			);
 			path = Chat;
 			sourceTree = "<group>";
@@ -775,6 +780,7 @@
 				AEFBE22E23FEF23D0045327A /* ProviderInfoCell.swift */,
 				AED62BCD247687E6009E220D /* LocationStreamingIndicator.swift */,
 				30F4BFED252E3E020006B9B3 /* PaddingTextView.swift */,
+				30349290256441E200A523D0 /* QuotePreview.swift */,
 			);
 			path = View;
 			sourceTree = "<group>";
@@ -1188,6 +1194,7 @@
 				AEE700252438E0E500D6992E /* ProgressAlertHandler.swift in Sources */,
 				30E348E524F6647D005C93D1 /* FileTextCell.swift in Sources */,
 				306C32322445CDE9001D89F3 /* DcLogger.swift in Sources */,
+				303492952565AABC00A523D0 /* DraftModel.swift in Sources */,
 				78E45E3A21D3CFBC00D4B15E /* SettingsController.swift in Sources */,
 				AE8519EA2272FDCA00ED86F0 /* DeviceContactsHandler.swift in Sources */,
 				78ED838321D5379000243125 /* TextFieldCell.swift in Sources */,
@@ -1218,6 +1225,7 @@
 				305961CF2346125100C80F33 /* UIColor+Extensions.swift in Sources */,
 				AEACE2E51FB32E1900DCDD78 /* Utils.swift in Sources */,
 				3052C60E253F088E007D13EA /* DetectorType.swift in Sources */,
+				30349291256441E200A523D0 /* QuotePreview.swift in Sources */,
 				AEC67A1E241FCFE0007DDBE1 /* ChatListViewModel.swift in Sources */,
 				30FDB71F24D8170E0066C48D /* TextMessageCell.swift in Sources */,
 				AE1988A523EB2FBA00B4CD5F /* Errors.swift in Sources */,

+ 3 - 3
deltachat-ios/Assets.xcassets/Contents.json

@@ -1,6 +1,6 @@
 {
   "info" : {
-    "version" : 1,
-    "author" : "xcode"
+    "author" : "xcode",
+    "version" : 1
   }
-}
+}

+ 23 - 0
deltachat-ios/Assets.xcassets/ic_reply.imageset/Contents.json

@@ -0,0 +1,23 @@
+{
+  "images" : [
+    {
+      "filename" : "baseline_reply_white_36pt_1x.png",
+      "idiom" : "universal",
+      "scale" : "1x"
+    },
+    {
+      "filename" : "baseline_reply_white_36pt_2x.png",
+      "idiom" : "universal",
+      "scale" : "2x"
+    },
+    {
+      "filename" : "baseline_reply_white_36pt_3x.png",
+      "idiom" : "universal",
+      "scale" : "3x"
+    }
+  ],
+  "info" : {
+    "author" : "xcode",
+    "version" : 1
+  }
+}

二进制
deltachat-ios/Assets.xcassets/ic_reply.imageset/baseline_reply_white_36pt_1x.png


二进制
deltachat-ios/Assets.xcassets/ic_reply.imageset/baseline_reply_white_36pt_2x.png


二进制
deltachat-ios/Assets.xcassets/ic_reply.imageset/baseline_reply_white_36pt_3x.png


+ 23 - 0
deltachat-ios/Assets.xcassets/ic_reply_black.imageset/Contents.json

@@ -0,0 +1,23 @@
+{
+  "images" : [
+    {
+      "filename" : "baseline_reply_white_36pt_1x.png",
+      "idiom" : "universal",
+      "scale" : "1x"
+    },
+    {
+      "filename" : "baseline_reply_white_36pt_2x.png",
+      "idiom" : "universal",
+      "scale" : "2x"
+    },
+    {
+      "filename" : "baseline_reply_white_36pt_3x.png",
+      "idiom" : "universal",
+      "scale" : "3x"
+    }
+  ],
+  "info" : {
+    "author" : "xcode",
+    "version" : 1
+  }
+}

二进制
deltachat-ios/Assets.xcassets/ic_reply_black.imageset/baseline_reply_white_36pt_1x.png


二进制
deltachat-ios/Assets.xcassets/ic_reply_black.imageset/baseline_reply_white_36pt_2x.png


二进制
deltachat-ios/Assets.xcassets/ic_reply_black.imageset/baseline_reply_white_36pt_3x.png


+ 72 - 14
deltachat-ios/Chat/ChatViewController.swift

@@ -8,6 +8,7 @@ import SDWebImage
 
 class ChatViewController: UITableViewController {
     var dcContext: DcContext
+    private var draftMessage: DcMsg?
     let outgoingAvatarOverlap: CGFloat = 17.5
     let loadCount = 30
     let chatId: Int
@@ -23,9 +24,21 @@ class ChatViewController: UITableViewController {
         return dcContext.getChat(chatId: chatId).isGroup
     }()
 
+    lazy var draft: DraftModel = {
+        let draft = DraftModel(chatId: chatId)
+        return draft
+    }()
+
     /// The `InputBarAccessoryView` used as the `inputAccessoryView` in the view controller.
     open var messageInputBar = InputBarAccessoryView()
 
+    lazy var quotePreview: QuotePreview = {
+        let view = QuotePreview()
+        view.delegate = self
+        view.translatesAutoresizingMaskIntoConstraints = false
+        return view
+    }()
+
     open override var shouldAutorotate: Bool {
         return false
     }
@@ -138,13 +151,15 @@ class ChatViewController: UITableViewController {
 
         if !disableWriting {
             configureMessageInputBar()
-            messageInputBar.inputTextView.text = textDraft
+            draft.parse(draftMsg: dcContext.getDraft(chatId: chatId))
+            messageInputBar.inputTextView.text = draft.draftText
+            configureDraftArea(draft: draft)
         }
 
 
         let notificationCenter = NotificationCenter.default
         notificationCenter.addObserver(self,
-                                       selector: #selector(setTextDraft),
+                                       selector: #selector(saveDraft),
                                        name: UIApplication.willResignActiveNotification,
                                        object: nil)
         notificationCenter.addObserver(self, selector: #selector(keyboardWillShow(_:)), name: UIResponder.keyboardWillShowNotification, object: nil)
@@ -287,7 +302,7 @@ class ChatViewController: UITableViewController {
     override func viewDidDisappear(_ animated: Bool) {
         super.viewDidDisappear(animated)
         AppStateRestorer.shared.resetLastActiveChat()
-        setTextDraft()
+        saveDraft()
         let nc = NotificationCenter.default
         if let msgChangedObserver = self.msgChangedObserver {
             nc.removeObserver(msgChangedObserver)
@@ -399,6 +414,34 @@ class ChatViewController: UITableViewController {
         markSeenMessagesInVisibleArea()
     }
 
+    private func configureDraftArea(draft: DraftModel) {
+        quotePreview.configure(draft: draft)
+        // setStackViewItems recalculates the proper messageInputBar height
+        messageInputBar.setStackViewItems([quotePreview], forStack: .top, animated: true)
+    }
+
+    override func tableView(_ tableView: UITableView, leadingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {
+        let action = UIContextualAction(style: .normal, title: nil,
+                                        handler: { (_, _, completionHandler) in
+                                            let message = DcMsg(id: self.messageIds[indexPath.row])
+                                            self.draft.setQuote(quotedMsg: message)
+                                            self.configureDraftArea(draft: self.draft)
+                                            self.messageInputBar.inputTextView.becomeFirstResponder()
+                                            completionHandler(true)
+                                        })
+        if #available(iOS 12.0, *) {
+            action.image = UIImage(named: traitCollection.userInterfaceStyle == .light ? "ic_reply_black" : "ic_reply")
+        } else {
+            action.image = UIImage(named: "ic_reply_black")
+        }
+        action.image?.accessibilityTraits = .button
+        action.image?.accessibilityLabel = String.localized("menu_reply")
+        action.backgroundColor = DcColors.chatBackgroundColor
+        let configuration = UISwipeActionsConfiguration(actions: [action])
+
+        return configuration
+    }
+
     func markSeenMessagesInVisibleArea() {
         if let indexPaths = tableView.indexPathsForVisibleRows {
             let visibleMessagesIds = indexPaths.map { UInt32(messageIds[$0.row]) }
@@ -595,19 +638,13 @@ class ChatViewController: UITableViewController {
             emptyStateView.isHidden = true
         }
     }
-
-    private var textDraft: String? {
-        return dcContext.getDraft(chatId: chatId)
-    }
     
     private func getMessageIds() -> [Int] {
         return dcContext.getMessageIds(chatId: chatId)
     }
 
-    @objc private func setTextDraft() {
-        if let text = self.messageInputBar.inputTextView.text {
-            dcContext.setDraft(chatId: chatId, draftText: text)
-        }
+    @objc private func saveDraft() {
+        draft.save(context: dcContext)
     }
 
     private func configureMessageInputBar() {
@@ -905,9 +942,19 @@ class ChatViewController: UITableViewController {
         }
     }
 
-    private func sendTextMessage(message: String) {
+    private func sendTextMessage() {
         DispatchQueue.global().async {
-            self.dcContext.sendTextInChat(id: self.chatId, message: message)
+            if let draftText = self.draft.draftText {
+                let message = DcMsg(viewType: DC_MSG_TEXT)
+                message.text = draftText.trimmingCharacters(in: .whitespacesAndNewlines)
+                if let quoteMessage = self.draft.quoteMessage {
+                    message.quoteMessage = quoteMessage
+                }
+                message.sendInChat(id: self.chatId)
+                DispatchQueue.main.async {
+                    self.quotePreview.cancel()
+                }
+            }
         }
     }
 
@@ -1167,7 +1214,7 @@ extension ChatViewController: MediaPickerDelegate {
 extension ChatViewController: InputBarAccessoryViewDelegate {
     func inputBar(_ inputBar: InputBarAccessoryView, didPressSendButtonWith text: String) {
         if inputBar.inputTextView.images.isEmpty {
-            self.sendTextMessage(message: text.trimmingCharacters(in: .whitespacesAndNewlines))
+            self.sendTextMessage()
         } else {
             let trimmedText = text.replacingOccurrences(of: "\u{FFFC}", with: "", options: .literal, range: nil)
                 .trimmingCharacters(in: .whitespacesAndNewlines)
@@ -1177,4 +1224,15 @@ extension ChatViewController: InputBarAccessoryViewDelegate {
         inputBar.inputTextView.text = String()
         inputBar.inputTextView.attributedText = nil
     }
+
+    func inputBar(_ inputBar: InputBarAccessoryView, textViewTextDidChangeTo text: String) {
+        draft.draftText = text
+    }
+}
+
+extension ChatViewController: QuotePreviewDelegate {
+    func onCancel() {
+        draft.setQuote(quotedMsg: nil)
+        configureDraftArea(draft: draft)
+    }
 }

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

@@ -0,0 +1,47 @@
+import Foundation
+import UIKit
+import DcCore
+
+public class DraftModel {
+    var quoteMessage: DcMsg?
+    var quoteText: String?
+    var draftText: String?
+    let chatId: Int
+
+    public init(chatId: Int) {
+        self.chatId = chatId
+    }
+
+    public func parse(draftMsg: DcMsg?) {
+        draftText = draftMsg?.text
+        quoteText = draftMsg?.quoteText
+        quoteMessage = draftMsg?.quoteMessage
+    }
+
+    public func setQuote(quotedMsg: DcMsg?) {
+        if let quotedMsg = quotedMsg {
+            // create a temporary draft to get the correct quoteText
+            let draftMessage = DcMsg(viewType: DC_MSG_TEXT)
+            draftMessage.quoteMessage = quotedMsg
+            self.quoteText = draftMessage.quoteText
+            self.quoteMessage = quotedMsg
+        } else {
+            self.quoteText = nil
+            self.quoteMessage = nil
+        }
+    }
+
+    public func save(context: DcContext) {
+        if draftText == nil && quoteMessage == nil {
+            context.setDraft(chatId: chatId, message: nil)
+            return
+        }
+
+        let draftMessage = DcMsg(viewType: DC_MSG_TEXT)
+        draftMessage.text = draftText
+        if quoteMessage != nil {
+            draftMessage.quoteMessage = quoteMessage
+        }
+        context.setDraft(chatId: chatId, message: draftMessage)
+    }
+}

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

@@ -42,7 +42,7 @@ public class QuoteView: UIView {
         fatalError("init(coder:) has not been implemented")
     }
 
-    func setupSubviews() {
+    private func setupSubviews() {
         addSubview(citeBar)
         addSubview(senderTitle)
         addSubview(imagePreview)
@@ -53,6 +53,7 @@ public class QuoteView: UIView {
             imagePreview.constraintHeightTo(36),
             imagePreview.constraintWidthTo(36),
             imagePreview.constraintCenterYTo(citeBar),
+            imagePreview.constraintAlignTopMaxTo(self),
             senderTitle.constraintAlignTopTo(self),
             senderTitle.constraintAlignLeadingTo(self, paddingLeading: 28),
             senderTitle.constraintTrailingToLeadingOf(imagePreview, paddingTrailing: 8),

+ 107 - 0
deltachat-ios/View/QuotePreview.swift

@@ -0,0 +1,107 @@
+import UIKit
+import InputBarAccessoryView
+import DcCore
+
+public protocol QuotePreviewDelegate: class {
+    func onCancel()
+}
+
+public class QuotePreview: 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 weak var delegate: QuotePreviewDelegate?
+
+    lazy var quoteView: QuoteView = {
+        let view = QuoteView()
+        view.translatesAutoresizingMaskIntoConstraints = false
+
+        return view
+    }()
+
+    lazy var cancelButton: UIView = {
+        let view = UIView()
+        view.isUserInteractionEnabled = true
+        view.translatesAutoresizingMaskIntoConstraints = false
+        view.addSubview(cancelImageView)
+        view.accessibilityLabel = String.localized("cancel")
+        view.isAccessibilityElement = true
+        return view
+    }()
+
+    private lazy var cancelImageView: UIImageView = {
+        let view = UIImageView(image: UIImage())
+        view.tintColor = .systemBlue
+        view.image = #imageLiteral(resourceName: "ic_close_36pt").withRenderingMode(.alwaysTemplate)
+        view.translatesAutoresizingMaskIntoConstraints = false
+        return view
+    }()
+
+    private lazy var upperBorder: UIView = {
+        let view = UIView()
+        view.backgroundColor = DcColors.colorDisabled
+        view.translatesAutoresizingMaskIntoConstraints = false
+        return view
+    }()
+
+    init() {
+        super.init(frame: .zero)
+        setupSubviews()
+    }
+
+    required init?(coder: NSCoder) {
+        fatalError("init(coder:) has not been implemented")
+    }
+
+    private func setupSubviews() {
+        addSubview(upperBorder)
+        addSubview(quoteView)
+        addSubview(cancelButton)
+        addConstraints([
+            upperBorder.constraintAlignLeadingTo(self),
+            upperBorder.constraintAlignTrailingTo(self),
+            upperBorder.constraintHeightTo(1),
+            upperBorder.constraintAlignTopTo(self, paddingTop: 4),
+            quoteView.constraintAlignTopTo(upperBorder, paddingTop: 4),
+            quoteView.constraintAlignLeadingTo(self),
+            quoteView.constraintAlignBottomTo(self, paddingBottom: 4),
+            quoteView.constraintTrailingToLeadingOf(cancelButton, paddingTrailing: -2),
+            cancelButton.constraintAlignTrailingTo(self, paddingTrailing: 14),
+            cancelButton.constraintWidthTo(36),
+            cancelButton.constraintHeightTo(36),
+            cancelImageView.constraintAlignLeadingTo(cancelButton, paddingLeading: 6),
+            cancelImageView.constraintAlignTrailingTo(cancelButton, paddingTrailing: 6),
+            cancelImageView.constraintAlignTopTo(cancelButton, paddingTop: 6),
+            cancelImageView.constraintAlignBottomTo(cancelButton, paddingBottom: 6),
+            cancelButton.constraintCenterYTo(self),
+        ])
+        let recognizer = UITapGestureRecognizer(target: self, action: #selector(cancel))
+        cancelButton.addGestureRecognizer(recognizer)
+    }
+
+    @objc public func cancel() {
+        quoteView.prepareForReuse()
+        delegate?.onCancel()
+    }
+
+    public func configure(draft: DraftModel) {
+        if let quoteText = draft.quoteText {
+            quoteView.quote.text = quoteText
+            if let quoteMessage = draft.quoteMessage {
+                let contact = quoteMessage.fromContact
+                quoteView.senderTitle.text = contact.displayName
+                quoteView.senderTitle.textColor = contact.color
+                quoteView.citeBar.backgroundColor = contact.color
+                quoteView.imagePreview.image = quoteMessage.image
+            }
+            isHidden = false
+        } else {
+            isHidden = true
+        }
+    }
+}