Эх сурвалжийг харах

Merge pull request #1372 from deltachat/in-chat-search

in chat-search
bjoern 3 жил өмнө
parent
commit
2e43f6fab9
23 өөрчлөгдсөн 500 нэмэгдсэн , 16 устгасан
  1. 1 0
      DcCore/DcCore/Helper/DcColors.swift
  2. 4 0
      deltachat-ios.xcodeproj/project.pbxproj
  3. 23 0
      deltachat-ios/Assets.xcassets/ic_chevron_down.imageset/Contents.json
  4. BIN
      deltachat-ios/Assets.xcassets/ic_chevron_down.imageset/arrows_bottom_chevron_direction_move_icon_1x.png
  5. BIN
      deltachat-ios/Assets.xcassets/ic_chevron_down.imageset/arrows_bottom_chevron_direction_move_icon_2x.png
  6. BIN
      deltachat-ios/Assets.xcassets/ic_chevron_down.imageset/arrows_bottom_chevron_direction_move_icon_3x.png
  7. 23 0
      deltachat-ios/Assets.xcassets/ic_chevron_up.imageset/Contents.json
  8. BIN
      deltachat-ios/Assets.xcassets/ic_chevron_up.imageset/arrow_chevron_direction_down_move_icon_1x.png
  9. BIN
      deltachat-ios/Assets.xcassets/ic_chevron_up.imageset/arrow_chevron_direction_down_move_icon_2x.png
  10. BIN
      deltachat-ios/Assets.xcassets/ic_chevron_up.imageset/arrow_chevron_direction_down_move_icon_3x.png
  11. 158 4
      deltachat-ios/Chat/ChatViewController.swift
  12. 8 2
      deltachat-ios/Chat/Views/Cells/AudioMessageCell.swift
  13. 17 1
      deltachat-ios/Chat/Views/Cells/BaseMessageCell.swift
  14. 8 2
      deltachat-ios/Chat/Views/Cells/FileTextCell.swift
  15. 8 2
      deltachat-ios/Chat/Views/Cells/ImageTextCell.swift
  16. 9 2
      deltachat-ios/Chat/Views/Cells/TextMessageCell.swift
  17. 131 0
      deltachat-ios/Chat/Views/ChatSearchAccessoryBar.swift
  18. 24 0
      deltachat-ios/Controller/ContactDetailViewController.swift
  19. 27 2
      deltachat-ios/Controller/GroupChatDetailViewController.swift
  20. 17 0
      deltachat-ios/Extensions/Extensions.swift
  21. 16 0
      deltachat-ios/Extensions/String+Extension.swift
  22. 24 0
      deltachat-ios/Helper/MessageUtils.swift
  23. 2 1
      deltachat-ios/ViewModel/ContactDetailViewModel.swift

+ 1 - 0
DcCore/DcCore/Helper/DcColors.swift

@@ -6,6 +6,7 @@ public struct DcColors {
 
 
     public static let primary = UIColor.systemBlue
+    public static let highlight = UIColor.themeColor(light: UIColor.yellow, dark: UIColor.systemBlue)
     public static let colorDisabled = UIColor.themeColor(light: UIColor(white: 0.9, alpha: 1), dark: UIColor(white: 0.2, alpha: 1))
     public static let messagePrimaryColor = UIColor.themeColor(light: UIColor.rgb(red: 220, green: 248, blue: 198),
                                                         dark: UIColor.init(hexString: "224508"))

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

@@ -74,6 +74,7 @@
 		30A4149724F6EFBE00EC91EB /* InfoMessageCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30A4149624F6EFBE00EC91EB /* InfoMessageCell.swift */; };
 		30B0ACFA24AB5B99004D5E29 /* SettingsEphemeralMessageController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30B0ACF924AB5B99004D5E29 /* SettingsEphemeralMessageController.swift */; };
 		30C0D49D237C4908008E2A0E /* CertificateCheckController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30C0D49C237C4908008E2A0E /* CertificateCheckController.swift */; };
+		30C2BFFE27032375005505DA /* ChatSearchAccessoryBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30C2BFFD27032375005505DA /* ChatSearchAccessoryBar.swift */; };
 		30E348DF24F3F819005C93D1 /* ChatTableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30E348DE24F3F819005C93D1 /* ChatTableView.swift */; };
 		30E348E124F53772005C93D1 /* ImageTextCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30E348E024F53772005C93D1 /* ImageTextCell.swift */; };
 		30E348E524F6647D005C93D1 /* FileTextCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30E348E424F6647D005C93D1 /* FileTextCell.swift */; };
@@ -325,6 +326,7 @@
 		30AC265E237F1807002A943F /* AvatarHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AvatarHelper.swift; sourceTree = "<group>"; };
 		30B0ACF924AB5B99004D5E29 /* SettingsEphemeralMessageController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsEphemeralMessageController.swift; sourceTree = "<group>"; };
 		30C0D49C237C4908008E2A0E /* CertificateCheckController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CertificateCheckController.swift; sourceTree = "<group>"; };
+		30C2BFFD27032375005505DA /* ChatSearchAccessoryBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatSearchAccessoryBar.swift; sourceTree = "<group>"; };
 		30E348DE24F3F819005C93D1 /* ChatTableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatTableView.swift; sourceTree = "<group>"; };
 		30E348E024F53772005C93D1 /* ImageTextCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageTextCell.swift; sourceTree = "<group>"; };
 		30E348E424F6647D005C93D1 /* FileTextCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileTextCell.swift; sourceTree = "<group>"; };
@@ -605,6 +607,7 @@
 				303492CE2587C2DC00A523D0 /* ChatInputBar.swift */,
 				3067AA4B2666310E00525036 /* ChatInputTextView.swift */,
 				307A82CB25B8D26700748B57 /* ChatEditingBar.swift */,
+				30C2BFFD27032375005505DA /* ChatSearchAccessoryBar.swift */,
 				302D54692693591700A8B271 /* ChatContactRequestBar.swift */,
 				302E1BB3252B5AB4008F4264 /* PlayButtonView.swift */,
 				30F8817524DA97DA0023780E /* BackgroundContainer.swift */,
@@ -1280,6 +1283,7 @@
 				30FDB70524D1C1000066C48D /* ChatViewController.swift in Sources */,
 				AE52EA20229EB9F000C586C9 /* EditGroupViewController.swift in Sources */,
 				AE18F294228C602A0007B1BE /* SecuritySettingsController.swift in Sources */,
+				30C2BFFE27032375005505DA /* ChatSearchAccessoryBar.swift in Sources */,
 				AE39D323249CFC1A007346A1 /* DocumentGalleryController.swift in Sources */,
 				AE8DD451249D1DFB009A4BC1 /* DocumentGalleryFileCell.swift in Sources */,
 				3095A351237DD1F700AB07F7 /* MediaPicker.swift in Sources */,

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

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

BIN
deltachat-ios/Assets.xcassets/ic_chevron_down.imageset/arrows_bottom_chevron_direction_move_icon_1x.png


BIN
deltachat-ios/Assets.xcassets/ic_chevron_down.imageset/arrows_bottom_chevron_direction_move_icon_2x.png


BIN
deltachat-ios/Assets.xcassets/ic_chevron_down.imageset/arrows_bottom_chevron_direction_move_icon_3x.png


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

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

BIN
deltachat-ios/Assets.xcassets/ic_chevron_up.imageset/arrow_chevron_direction_down_move_icon_1x.png


BIN
deltachat-ios/Assets.xcassets/ic_chevron_up.imageset/arrow_chevron_direction_down_move_icon_2x.png


BIN
deltachat-ios/Assets.xcassets/ic_chevron_up.imageset/arrow_chevron_direction_down_move_icon_3x.png


+ 158 - 4
deltachat-ios/Chat/ChatViewController.swift

@@ -36,6 +36,33 @@ class ChatViewController: UITableViewController {
         return draft
     }()
 
+    // search related
+    private var activateSearch: Bool = false
+    private var searchMessageIds: [Int] = []
+    private var searchResultIndex: Int = 0
+    private var debounceTimer: Timer?
+
+    lazy var searchController: UISearchController = {
+        let searchController = UISearchController(searchResultsController: nil)
+        searchController.obscuresBackgroundDuringPresentation = false
+        searchController.searchBar.placeholder = String.localized("search")
+        searchController.searchBar.delegate = self
+        searchController.delegate = self
+        searchController.searchResultsUpdater = self
+        searchController.searchBar.inputAccessoryView = messageInputBar
+        searchController.searchBar.autocorrectionType = .yes
+        searchController.searchBar.keyboardType = .default
+        return searchController
+    }()
+
+    public lazy var searchAccessoryBar: ChatSearchAccessoryBar = {
+        let view = ChatSearchAccessoryBar()
+        view.delegate = self
+        view.translatesAutoresizingMaskIntoConstraints = false
+        view.isEnabled = false
+        return view
+    }()
+
     /// The `InputBarAccessoryView` used as the `inputAccessoryView` in the view controller.
     open var messageInputBar = ChatInputBar()
 
@@ -261,6 +288,7 @@ class ChatViewController: UITableViewController {
         tableView.contentInsetAdjustmentBehavior = .never
         navigationController?.setNavigationBarHidden(false, animated: false)
         navigationItem.backButtonTitle = String.localized("chat")
+        definesPresentationContext = true
 
         if !dcContext.isConfigured() {
             // TODO: display message about nothing being configured
@@ -285,7 +313,7 @@ class ChatViewController: UITableViewController {
     }
 
     private func getTopInsetHeight() -> CGFloat {
-        let navigationBarHeight = (navigationController?.navigationBar.bounds.height ?? 0)
+        let navigationBarHeight = navigationController?.navigationBar.bounds.height ?? 0
         if let root = UIApplication.shared.keyWindow?.rootViewController {
             return navigationBarHeight + root.view.safeAreaInsets.top
         }
@@ -311,6 +339,11 @@ class ChatViewController: UITableViewController {
         }
     }
 
+    public func activateSearchOnAppear() {
+        activateSearch = true
+        navigationItem.searchController = self.searchController
+    }
+
     private func stopTimer() {
         if let timer = timer {
             timer.invalidate()
@@ -331,6 +364,13 @@ class ChatViewController: UITableViewController {
         }
         if !isDismissing {
             self.tableView.becomeFirstResponder()
+            if activateSearch {
+                activateSearch = false
+                DispatchQueue.main.async { [weak self] in
+                    self?.searchController.isActive = true
+                }
+                
+            }
             var bottomInsets = self.messageInputBar.intrinsicContentSize.height + self.messageInputBar.keyboardHeight
             if UIApplication.shared.statusBarOrientation.isLandscape,
                let root = UIApplication.shared.keyWindow?.rootViewController {
@@ -635,7 +675,9 @@ class ChatViewController: UITableViewController {
                     msg: message,
                     messageStyle: configureMessageStyle(for: message, at: indexPath),
                     showAvatar: showAvatar,
-                    showName: showName)
+                    showName: showName,
+                    searchText: searchController.searchBar.text,
+                    highlight: !searchMessageIds.isEmpty && message.id == searchMessageIds[searchResultIndex])
 
         return cell
     }
@@ -660,6 +702,15 @@ class ChatViewController: UITableViewController {
     }
 
     private func configureDraftArea(draft: DraftModel, animated: Bool = true) {
+        if searchController.isActive {
+            messageInputBar.setMiddleContentView(searchAccessoryBar, animated: false)
+            messageInputBar.setLeftStackViewWidthConstant(to: 0, animated: false)
+            messageInputBar.setRightStackViewWidthConstant(to: 0, animated: false)
+            messageInputBar.setStackViewItems([], forStack: .top, animated: false)
+            messageInputBar.padding = UIEdgeInsets(top: 6, left: 0, bottom: 6, right: 0)
+            return
+        }
+
         draftArea.configure(draft: draft)
         if draft.isEditing {
             messageInputBar.setMiddleContentView(editingBar, animated: false)
@@ -907,13 +958,32 @@ class ChatViewController: UITableViewController {
         }
     }
 
-    private func scrollToMessage(msgId: Int, animated: Bool = true) {
+    private func scrollToMessage(msgId: Int, animated: Bool = true, scrollToText: Bool = false) {
         DispatchQueue.main.async { [weak self] in
             guard let self = self else { return }
             guard let index = self.messageIds.firstIndex(of: msgId) else {
                 return
             }
             let indexPath = IndexPath(row: index, section: 0)
+
+            if scrollToText {
+                self.tableView.scrollToRow(at: indexPath, at: .top, animated: false)
+                let cell = self.tableView.cellForRow(at: indexPath)
+                if let messageCell = cell as? BaseMessageCell {
+                    let textYPos = messageCell.getTextOffset(of: self.searchController.searchBar.text)
+                    let currentYPos = self.tableView.contentOffset.y
+                    let padding: CGFloat = 12
+                    self.tableView.setContentOffset(CGPoint(x: 0,
+                                                            y: textYPos +
+                                                                currentYPos -
+                                                                2 * UIFont.preferredFont(for: .body, weight: .regular).lineHeight -
+                                                                padding),
+                                                    animated: false)
+
+                    return
+                }
+            }
+
             self.tableView.scrollToRow(at: indexPath, at: .top, animated: animated)
         }
     }
@@ -1750,6 +1820,91 @@ extension ChatViewController: ChatEditingDelegate {
     }
 }
 
+// MARK: - ChatSearchDelegate
+extension ChatViewController: ChatSearchDelegate {
+    func onSearchPreviousPressed() {
+        logger.debug("onSearch Previous Pressed")
+        if searchResultIndex == 0 && !searchMessageIds.isEmpty {
+            searchResultIndex = searchMessageIds.count - 1
+        } else {
+            searchResultIndex -= 1
+        }
+        scrollToMessage(msgId: searchMessageIds[searchResultIndex], animated: true, scrollToText: true)
+        searchAccessoryBar.updateSearchResult(sum: self.searchMessageIds.count, position: searchResultIndex + 1)
+        self.reloadData()
+    }
+
+    func onSearchNextPressed() {
+        logger.debug("onSearch Next Pressed")
+        if searchResultIndex == searchMessageIds.count - 1 {
+            searchResultIndex = 0
+        } else {
+            searchResultIndex += 1
+        }
+        scrollToMessage(msgId: searchMessageIds[searchResultIndex], animated: true, scrollToText: true)
+        searchAccessoryBar.updateSearchResult(sum: self.searchMessageIds.count, position: searchResultIndex + 1)
+        self.reloadData()
+    }
+}
+
+// MARK: UISearchResultUpdating
+extension ChatViewController: UISearchResultsUpdating {
+    func updateSearchResults(for searchController: UISearchController) {
+        logger.debug("searchbar: \(String(describing: searchController.searchBar.text))")
+        debounceTimer?.invalidate()
+        debounceTimer = Timer.scheduledTimer(withTimeInterval: 0.25, repeats: false) { _ in
+            let searchText = searchController.searchBar.text ?? ""
+            DispatchQueue.global(qos: .userInteractive).async {
+                let resultIds = self.dcContext.searchMessages(chatId: self.chatId, searchText: searchText)
+                DispatchQueue.main.async { [weak self] in
+
+                guard let self = self else { return }
+                    self.searchMessageIds = resultIds
+                    self.searchResultIndex = self.searchMessageIds.isEmpty ? 0 : self.searchMessageIds.count - 1
+                    self.searchAccessoryBar.isEnabled = !resultIds.isEmpty
+                    self.searchAccessoryBar.updateSearchResult(sum: self.searchMessageIds.count, position: self.searchResultIndex + 1)
+
+                    if let lastId = resultIds.last {
+                        self.scrollToMessage(msgId: lastId, animated: true, scrollToText: true)
+                    }
+                    self.reloadData()
+                }
+            }
+        }
+    }
+}
+
+// MARK: - UISearchBarDelegate
+extension ChatViewController: UISearchBarDelegate {
+
+    func searchBarShouldBeginEditing(_ searchBar: UISearchBar) -> Bool {
+        configureDraftArea(draft: draft)
+        return true
+    }
+
+    func searchBarTextDidEndEditing(_ searchBar: UISearchBar) {
+        configureDraftArea(draft: draft)
+        tableView.becomeFirstResponder()
+    }
+
+    func searchBarCancelButtonClicked(_ searchBar: UISearchBar) {
+        searchController.isActive = false
+        configureDraftArea(draft: draft)
+        tableView.becomeFirstResponder()
+        navigationItem.searchController = nil
+        reloadData()
+    }
+}
+
+// MARK: - UISearchControllerDelegate
+extension ChatViewController: UISearchControllerDelegate {
+    func didPresentSearchController(_ searchController: UISearchController) {
+        DispatchQueue.main.async { [weak self] in
+            self?.searchController.searchBar.becomeFirstResponder()
+        }
+    }
+}
+
 // MARK: - ChatContactRequestBar
 extension ChatViewController: ChatContactRequestDelegate {
     func onAcceptRequest() {
@@ -1818,4 +1973,3 @@ extension ChatViewController: ChatInputTextViewPasteDelegate {
         sendSticker(image)
     }
 }
-

+ 8 - 2
deltachat-ios/Chat/Views/Cells/AudioMessageCell.swift

@@ -39,7 +39,7 @@ public class AudioMessageCell: BaseMessageCell {
         delegate?.playButtonTapped(cell: self, messageId: messageId)
     }
 
-    override func update(dcContext: DcContext, msg: DcMsg, messageStyle: UIRectCorner, showAvatar: Bool, showName: Bool) {
+    override func update(dcContext: DcContext, msg: DcMsg, messageStyle: UIRectCorner, showAvatar: Bool, showName: Bool, searchText: String? = nil, highlight: Bool) {
         messageId = msg.id
         if let text = msg.text {
             mainContentView.spacing = text.isEmpty ? 0 : 8
@@ -61,7 +61,13 @@ public class AudioMessageCell: BaseMessageCell {
         })
         
 
-        super.update(dcContext: dcContext, msg: msg, messageStyle: messageStyle, showAvatar: showAvatar, showName: showName)
+        super.update(dcContext: dcContext,
+                     msg: msg,
+                     messageStyle: messageStyle,
+                     showAvatar: showAvatar,
+                     showName: showName,
+                     searchText: searchText,
+                     highlight: highlight)
     }
 
     public override func prepareForReuse() {

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

@@ -274,7 +274,7 @@ public class BaseMessageCell: UITableViewCell {
     }
 
     // update classes inheriting BaseMessageCell first before calling super.update(...)
-    func update(dcContext: DcContext, msg: DcMsg, messageStyle: UIRectCorner, showAvatar: Bool, showName: Bool) {
+    func update(dcContext: DcContext, msg: DcMsg, messageStyle: UIRectCorner, showAvatar: Bool, showName: Bool, searchText: String?, highlight: Bool) {
         let fromContact = dcContext.getContact(id: msg.fromContactId)
         if msg.isFromCurrentSender {
             topLabel.text = msg.isForwarded ? String.localized("forwarded_message") : nil
@@ -360,6 +360,10 @@ public class BaseMessageCell: UITableViewCell {
             quoteView.isHidden = true
         }
 
+        messageLabel.attributedText = MessageUtils.getFormattedSearchResultMessage(messageText: msg.text,
+                                                                                       searchText: searchText,
+                                                                                       highlight: highlight)
+
         messageLabel.delegate = self
         accessibilityLabel = configureAccessibilityString(message: msg)
     }
@@ -402,6 +406,18 @@ public class BaseMessageCell: UITableViewCell {
         return backgroundColor
     }
 
+    func getTextOffset(of text: String?) -> CGFloat {
+        guard let text = text else { return 0 }
+        let offsetInLabel = messageLabel.label.offsetOfSubstring(text)
+        if offsetInLabel == 0 {
+            return 0
+        }
+
+        let labelTop = CGPoint(x: messageLabel.label.bounds.minX, y: messageLabel.label.bounds.minY)
+        let point = messageLabel.label.convert(labelTop, to: self)
+        return point.y + offsetInLabel
+    }
+
     override public func prepareForReuse() {
         accessibilityLabel = nil
         textLabel?.text = nil

+ 8 - 2
deltachat-ios/Chat/Views/Cells/FileTextCell.swift

@@ -29,7 +29,7 @@ class FileTextCell: BaseMessageCell {
         fileView.prepareForReuse()
     }
 
-    override func update(dcContext: DcContext, msg: DcMsg, messageStyle: UIRectCorner, showAvatar: Bool, showName: Bool) {
+    override func update(dcContext: DcContext, msg: DcMsg, messageStyle: UIRectCorner, showAvatar: Bool, showName: Bool, searchText: String? = nil, highlight: Bool) {
         if let text = msg.text, !text.isEmpty {
             messageLabel.text = text
             spacer?.isActive = true
@@ -39,7 +39,13 @@ class FileTextCell: BaseMessageCell {
         
         fileView.configure(message: msg)
         accessibilityLabel = "\(String.localized("document")), \(fileView.configureAccessibilityLabel())"
-        super.update(dcContext: dcContext, msg: msg, messageStyle: messageStyle, showAvatar: showAvatar, showName: showName)
+        super.update(dcContext: dcContext,
+                     msg: msg,
+                     messageStyle: messageStyle,
+                     showAvatar: showAvatar,
+                     showName: showName,
+                     searchText: searchText,
+                     highlight: highlight)
     }
     
 }

+ 8 - 2
deltachat-ios/Chat/Views/Cells/ImageTextCell.swift

@@ -42,7 +42,7 @@ class ImageTextCell: BaseMessageCell {
         contentImageView.addGestureRecognizer(gestureRecognizer)
     }
 
-    override func update(dcContext: DcContext, msg: DcMsg, messageStyle: UIRectCorner, showAvatar: Bool, showName: Bool) {
+    override func update(dcContext: DcContext, msg: DcMsg, messageStyle: UIRectCorner, showAvatar: Bool, showName: Bool, searchText: String? = nil, highlight: Bool) {
         messageLabel.text = msg.text
         let hasEmptyText = msg.text?.isEmpty ?? true
         bottomCompactView = msg.type != DC_MSG_STICKER && !msg.hasHtml && hasEmptyText
@@ -93,7 +93,13 @@ class ImageTextCell: BaseMessageCell {
                 setAspectRatioFor(message: msg, with: placeholderImage, isPlaceholder: true)
             }
         }
-        super.update(dcContext: dcContext, msg: msg, messageStyle: messageStyle, showAvatar: showAvatar, showName: showName)
+        super.update(dcContext: dcContext,
+                     msg: msg,
+                     messageStyle: messageStyle,
+                     showAvatar: showAvatar,
+                     showName: showName,
+                     searchText: searchText,
+                     highlight: highlight)
     }
 
     @objc func onImageTapped() {

+ 9 - 2
deltachat-ios/Chat/Views/Cells/TextMessageCell.swift

@@ -11,9 +11,16 @@ class TextMessageCell: BaseMessageCell {
         messageLabel.paddingTrailing = 12
     }
 
-    override func update(dcContext: DcContext, msg: DcMsg, messageStyle: UIRectCorner, showAvatar: Bool, showName: Bool) {
+    override func update(dcContext: DcContext, msg: DcMsg, messageStyle: UIRectCorner, showAvatar: Bool, showName: Bool, searchText: String?, highlight: Bool) {
         messageLabel.text = msg.text
-        super.update(dcContext: dcContext, msg: msg, messageStyle: messageStyle, showAvatar: showAvatar, showName: showName)
+
+        super.update(dcContext: dcContext,
+                     msg: msg,
+                     messageStyle: messageStyle,
+                     showAvatar: showAvatar,
+                     showName: showName,
+                     searchText: searchText,
+                     highlight: highlight)
     }
 
     override func prepareForReuse() {

+ 131 - 0
deltachat-ios/Chat/Views/ChatSearchAccessoryBar.swift

@@ -0,0 +1,131 @@
+import UIKit
+import InputBarAccessoryView
+import DcCore
+
+public protocol ChatSearchDelegate: class {
+    func onSearchPreviousPressed()
+    func onSearchNextPressed()
+}
+
+public class ChatSearchAccessoryBar: 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) {
+            upButton.isEnabled = newValue
+            downButton.isEnabled = newValue
+        }
+    }
+
+    weak var delegate: ChatSearchDelegate?
+
+    private lazy var upButton: UIButton = {
+        let view = UIButton()
+
+        if #available(iOS 13.0, *) {
+            view.setImage(UIImage(systemName: "chevron.up"), for: .normal)
+            view.tintColor = .systemBlue
+        } else {
+            view.setImage(UIImage(named: "ic_chevron_up")?.sd_tintedImage(with: .systemBlue), for: .normal)
+        }
+        view.translatesAutoresizingMaskIntoConstraints = false
+        view.isUserInteractionEnabled = true
+        view.imageView?.contentMode = .scaleAspectFit
+        return view
+    }()
+
+    private lazy var downButton: UIButton = {
+        let view = UIButton()
+        view.tintColor = .systemBlue
+        if #available(iOS 13.0, *) {
+            view.setImage(UIImage(systemName: "chevron.down"), for: .normal)
+            view.tintColor = .systemBlue
+        } else {
+            view.setImage(UIImage(named: "ic_chevron_down")?.sd_tintedImage(with: .systemBlue), for: .normal)
+        }
+        view.translatesAutoresizingMaskIntoConstraints = false
+        view.imageView?.contentMode = .scaleAspectFit
+        view.isUserInteractionEnabled = true
+        return view
+    }()
+
+    private lazy var searchResultLabel: UILabel = {
+        let view = UILabel(frame: .zero)
+        view.font = UIFont.preferredFont(for: .body, weight: .regular)
+        view.textColor = DcColors.grayDateColor
+        view.textAlignment = .center
+        view.translatesAutoresizingMaskIntoConstraints = false
+        return view
+    }()
+
+    private lazy var buttonContainer: UIStackView = {
+        let view = UIStackView(arrangedSubviews: [upButton, downButton])
+        view.axis = .horizontal
+        view.distribution = .equalSpacing
+        view.alignment = .trailing
+        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(searchResultLabel)
+        addSubview(buttonContainer)
+
+        addConstraints([
+            searchResultLabel.constraintCenterYTo(self),
+            searchResultLabel.constraintAlignLeadingToAnchor(self.safeAreaLayoutGuide.leadingAnchor, paddingLeading: 32),
+            buttonContainer.constraintAlignTopTo(self, paddingTop: 4),
+            buttonContainer.constraintAlignBottomTo(self, paddingBottom: 4),
+            buttonContainer.constraintWidthTo(90),
+            buttonContainer.constraintAlignTrailingToAnchor(self.safeAreaLayoutGuide.trailingAnchor, paddingTrailing: 12),
+            upButton.constraintHeightTo(40),
+            downButton.constraintHeightTo(40),
+            upButton.constraintWidthTo(40),
+            downButton.constraintWidthTo(40)
+        ])
+
+        backgroundColor = DcColors.chatBackgroundColor
+
+        let upGestaureListener = UITapGestureRecognizer(target: self, action: #selector(onUpPressed))
+        upButton.addGestureRecognizer(upGestaureListener)
+
+        let downGestureListener = UITapGestureRecognizer(target: self, action: #selector(onDownPressed))
+        downButton.addGestureRecognizer(downGestureListener)
+    }
+
+    @objc func onUpPressed() {
+        delegate?.onSearchPreviousPressed()
+    }
+
+    @objc func onDownPressed() {
+        delegate?.onSearchNextPressed()
+    }
+
+    public func updateSearchResult(sum: Int, position: Int) {
+        if sum == 0 {
+            searchResultLabel.text = nil
+        } else {
+            searchResultLabel.text = "\(position) / \(sum)"
+        }
+    }
+}

+ 24 - 0
deltachat-ios/Controller/ContactDetailViewController.swift

@@ -89,6 +89,17 @@ class ContactDetailViewController: UITableViewController {
         return cell
     }()
 
+    private lazy var searchCell: UITableViewCell = {
+        let cell = UITableViewCell(style: .value1, reuseIdentifier: nil)
+        cell.textLabel?.text = String.localized("search")
+        cell.accessoryType = .disclosureIndicator
+        if viewModel.chatId == 0 {
+            cell.isUserInteractionEnabled = false
+            cell.textLabel?.isEnabled = false
+        }
+        return cell
+    }()
+
     private lazy var statusCell: MultilineLabelCell = {
         let cell = MultilineLabelCell()
         cell.multilineDelegate = self
@@ -170,6 +181,8 @@ class ContactDetailViewController: UITableViewController {
                 return documentsCell
             case .gallery:
                 return galleryCell
+            case .search:
+                return searchCell
             case .ephemeralMessages:
                 return ephemeralMessagesCell
             case .muteChat:
@@ -333,6 +346,8 @@ class ContactDetailViewController: UITableViewController {
             showDocuments()
         case .gallery:
             showGallery()
+        case .search:
+            showSearch()
         case .ephemeralMessages:
             showEphemeralMessagesController()
         case .muteChat:
@@ -473,6 +488,15 @@ class ContactDetailViewController: UITableViewController {
         navigationController?.pushViewController(galleryController, animated: true)
     }
 
+    private func showSearch() {
+        if let chatViewController = navigationController?.viewControllers.last(where: {
+            $0 is ChatViewController
+        }) as? ChatViewController {
+            chatViewController.activateSearchOnAppear()
+            navigationController?.popViewController(animated: true)
+        }
+    }
+
     private func showContactAvatarIfNeeded() {
         if viewModel.isSavedMessages {
             let chat = viewModel.context.getChat(chatId: viewModel.chatId)

+ 27 - 2
deltachat-ios/Controller/GroupChatDetailViewController.swift

@@ -14,6 +14,7 @@ class GroupChatDetailViewController: UIViewController {
     enum ChatOption {
         case gallery
         case documents
+        case search
         case ephemeralMessages
         case muteChat
     }
@@ -157,13 +158,24 @@ class GroupChatDetailViewController: UIViewController {
         return cell
     }()
 
+    private lazy var searchCell: UITableViewCell = {
+        let cell = UITableViewCell(style: .value1, reuseIdentifier: nil)
+        cell.textLabel?.text = String.localized("search")
+        cell.accessoryType = .disclosureIndicator
+        if chatId == 0 {
+            cell.isUserInteractionEnabled = false
+            cell.textLabel?.isEnabled = false
+        }
+        return cell
+    }()
+
     init(chatId: Int, dcContext: DcContext) {
         self.dcContext = dcContext
         self.chatId = chatId
 
         let chat = dcContext.getChat(chatId: chatId)
         if chat.isMailinglist {
-            self.chatOptions = [.gallery, .documents, .muteChat]
+            self.chatOptions = [.gallery, .documents, .search, .muteChat]
             self.chatActions = [.archiveChat, .deleteChat]
             self.memberManagementRows = 2
             self.sections = [.chatOptions, .chatActions]
@@ -173,7 +185,7 @@ class GroupChatDetailViewController: UIViewController {
             self.memberManagementRows = 1
             self.sections = [.chatOptions, .members, .chatActions]
         } else {
-            self.chatOptions = [.gallery, .documents, .ephemeralMessages, .muteChat]
+            self.chatOptions = [.gallery, .documents, .search, .ephemeralMessages, .muteChat]
             self.chatActions = [.archiveChat, .leaveGroup, .deleteChat]
             self.memberManagementRows = 2
             self.sections = [.chatOptions, .members, .chatActions]
@@ -389,6 +401,15 @@ class GroupChatDetailViewController: UIViewController {
         navigationController?.pushViewController(galleryController, animated: true)
     }
 
+    private func showSearch() {
+        if let chatViewController = navigationController?.viewControllers.last(where: {
+            $0 is ChatViewController
+        }) as? ChatViewController {
+            chatViewController.activateSearchOnAppear()
+            navigationController?.popViewController(animated: true)
+        }
+    }
+
     private func deleteChat() {
         dcContext.deleteChat(chatId: chatId)
         NotificationManager.removeNotificationsForChat(dcContext: dcContext, chatId: chatId)
@@ -450,6 +471,8 @@ extension GroupChatDetailViewController: UITableViewDelegate, UITableViewDataSou
                 return galleryCell
             case .documents:
                 return documentsCell
+            case .search:
+                return searchCell
             case .ephemeralMessages:
                 return ephemeralMessagesCell
             case .muteChat:
@@ -508,6 +531,8 @@ extension GroupChatDetailViewController: UITableViewDelegate, UITableViewDataSou
                 showGallery()
             case .documents:
                 showDocuments()
+            case .search:
+                showSearch()
             case .ephemeralMessages:
                 showEphemeralMessagesController()
             case .muteChat:

+ 17 - 0
deltachat-ios/Extensions/Extensions.swift

@@ -99,3 +99,20 @@ extension UINavigationController {
         }
     }
 }
+
+extension UILabel {
+    func offsetOfSubstring(_ substring: String) -> CGFloat {
+        guard let text = text else {
+            return 0
+        }
+
+        let searchIndexes = text.ranges(of: substring, options: .caseInsensitive)
+        guard let firstIndex = searchIndexes.first else {
+            return 0
+        }
+
+        let prefix = text.substring(to: firstIndex.lowerBound)
+        let size: CGSize = prefix.size(withAttributes: [NSAttributedString.Key.font: font])
+        return size.height
+    }
+}

+ 16 - 0
deltachat-ios/Extensions/String+Extension.swift

@@ -9,6 +9,22 @@ extension String {
         return String(self[idx1..<idx2])
     }
 
+    func substring(to: Index) -> String {
+        return String(self[startIndex..<to])
+    }
+
+    func ranges(of string: String, options: String.CompareOptions = []) -> [Range<Index>] {
+        var result: [Range<Index>] = []
+        var startIndex = self.startIndex
+        while startIndex < endIndex,
+            let range = self[startIndex...].range(of: string, options: options) {
+                result.append(range)
+                startIndex = range.lowerBound < range.upperBound ? range.upperBound :
+                    index(range.lowerBound, offsetBy: 1, limitedBy: endIndex) ?? endIndex
+        }
+        return result
+    }
+
     // O(n) - returns indexes of subsequences -> can be used to highlight subsequence within string
     func contains(subSequence: String) -> [Int] {
         if subSequence.count > count {

+ 24 - 0
deltachat-ios/Helper/MessageUtils.swift

@@ -133,4 +133,28 @@ public class MessageUtils {
         let sendingState = "\(MessageUtils.getSendingStateString(message.state))"
         return "\(date) \(padlock) \(sendingState)"
     }
+
+    public static func getFormattedSearchResultMessage(messageText: String?, searchText: String?, highlight: Bool) -> NSAttributedString? {
+        if let messageText = messageText {
+            let fontAttributes: [NSAttributedString.Key: Any] = [
+                .font: UIFont.preferredFont(for: .body, weight: .regular),
+                .foregroundColor: DcColors.defaultTextColor
+            ]
+            let mutableAttributedString = NSMutableAttributedString(string: messageText, attributes: fontAttributes)
+
+            if let searchText = searchText {
+                let ranges = messageText.ranges(of: searchText, options: .caseInsensitive)
+                for range in ranges {
+                    let nsRange = NSRange(range, in: messageText)
+                    mutableAttributedString.addAttribute(.font, value: UIFont.preferredFont(for: .body, weight: .semibold), range: nsRange)
+                    if highlight {
+                        mutableAttributedString.addAttribute(.backgroundColor, value: DcColors.highlight, range: nsRange)
+                    }
+                }
+            }
+            return mutableAttributedString
+        }
+
+        return nil
+    }
 }

+ 2 - 1
deltachat-ios/ViewModel/ContactDetailViewModel.swift

@@ -15,6 +15,7 @@ class ContactDetailViewModel {
     enum ChatOption {
         case gallery
         case documents
+        case search
         case ephemeralMessages
         case muteChat
         case startChat
@@ -71,7 +72,7 @@ class ContactDetailViewModel {
         sections.append(.chatActions)
 
         if chatId != 0 {
-            chatOptions = [.gallery, .documents]
+            chatOptions = [.gallery, .documents, .search]
             if !isDeviceTalk {
                 chatOptions.append(.ephemeralMessages)
             }