From 7c054042c3a2f478f30a54dd34526805cfa4d6cc Mon Sep 17 00:00:00 2001 From: Soner YUKSEL Date: Tue, 2 Nov 2021 09:49:51 -0400 Subject: [PATCH] Fix #3397: Add ability to search bookmarks by title or URL in the bookmarks list (#4411) --- BraveShared/BraveStrings.swift | 2 + .../Bookmarks/BookmarksViewController.swift | 595 ++++++++++++------ .../BraveCore/Bookmarks/BookmarkManager.swift | 26 + 3 files changed, 429 insertions(+), 194 deletions(-) diff --git a/BraveShared/BraveStrings.swift b/BraveShared/BraveStrings.swift index 98cfe85f57b..2e0c2fbc7cf 100644 --- a/BraveShared/BraveStrings.swift +++ b/BraveShared/BraveStrings.swift @@ -87,6 +87,8 @@ extension Strings { public static let searchSuggestionSectionTitleFormat = NSLocalizedString("SearchSuggestionSectionTitleFormat", tableName: "BraveShared", bundle: Bundle.braveShared, value: "%@ Search", comment: "Section Title when showing search suggestions. The parameter substituted for \"%@\" is the name of the search engine. E.g.: Google Search") public static let turnOnSearchSuggestions = NSLocalizedString("Turn on search suggestions?", bundle: Bundle.braveShared, comment: "Prompt shown before enabling provider search queries") public static let searchSuggestionSectionTitleNoSearchFormat = NSLocalizedString("SearchSuggestionSectionTitleNoSearchFormat", tableName: "BraveShared", bundle: Bundle.braveShared, value: "Search", comment: "Section Title when showing search suggestions and the engine does not contain the word 'Search'.") + public static let noSearchResultsfound = NSLocalizedString("noSearchResultsfound", tableName: "BraveShared", bundle: .braveShared, value: "No search results found.", comment: "The information title displayed when there is no search reault found") + public static let searchBookmarksTitle = NSLocalizedString("searchBookmarksTitle", tableName: "BraveShared", bundle: .braveShared, value: "Search Bookmarks", comment: "The placeholder text for bookmark search") } // MARK:- Authenticator.swift diff --git a/Client/Frontend/Browser/Toolbars/BottomToolbar/Menu/Bookmarks/BookmarksViewController.swift b/Client/Frontend/Browser/Toolbars/BottomToolbar/Menu/Bookmarks/BookmarksViewController.swift index 1b85df5cff1..eae3e3a28b0 100644 --- a/Client/Frontend/Browser/Toolbars/BottomToolbar/Menu/Bookmarks/BookmarksViewController.swift +++ b/Client/Frontend/Browser/Toolbars/BottomToolbar/Menu/Bookmarks/BookmarksViewController.swift @@ -12,20 +12,21 @@ import CoreServices private let log = Logger.browserLogger class BookmarksViewController: SiteTableViewController, ToolbarUrlActionsProtocol { + + private var bookmarksFRC: BookmarksV2FetchResultsController? + private let bookmarkManager: BookmarkManager /// Called when the bookmarks are updated via some user input (i.e. Delete, edit, etc.) - var bookmarksDidChange: (() -> Void)? + private var bookmarksDidChange: (() -> Void)? weak var toolbarUrlActionsDelegate: ToolbarUrlActionsDelegate? - var bookmarksFRC: BookmarksV2FetchResultsController? - private let bookmarkManager: BookmarkManager - lazy var editBookmarksButton: UIBarButtonItem? = UIBarButtonItem().then { + private lazy var editBookmarksButton: UIBarButtonItem? = UIBarButtonItem().then { $0.image = #imageLiteral(resourceName: "edit").template $0.style = .plain $0.target = self $0.action = #selector(onEditBookmarksButton) } - lazy var addFolderButton: UIBarButtonItem? = UIBarButtonItem().then { + private lazy var addFolderButton: UIBarButtonItem? = UIBarButtonItem().then { $0.image = #imageLiteral(resourceName: "bookmarks_newfolder_icon").template $0.style = .plain $0.target = self @@ -47,13 +48,48 @@ class BookmarksViewController: SiteTableViewController, ToolbarUrlActionsProtoco $0.isHidden = true } - weak var addBookmarksFolderOkAction: UIAlertAction? + private var leftToolbarItems: [UIBarButtonItem?] { + var items: [UIBarButtonItem?] = [.fixedSpace(5)] + if currentFolder == nil { + items.append(importExportButton) + + // Unlike Chromium, old CoreData implementation did not have permanent folders + if !Preferences.Chromium.syncV2BookmarksMigrationCompleted.value { + items.append(.fixedSpace(16)) + items.append(addFolderButton) + } + } else { + items.append(addFolderButton) + } + + return items + } + + private var isLoading = false { + didSet { + if isLoading { + view.addSubview(spinner) + spinner.snp.makeConstraints { + $0.center.equalTo(view.snp.center) + } + + spinner.startAnimating() + spinner.isHidden = false + } else { + spinner.stopAnimating() + spinner.removeFromSuperview() + } + } + } + + private weak var addBookmarksFolderOkAction: UIAlertAction? - var isEditingIndividualBookmark: Bool = false + private var isEditingIndividualBookmark = false + + private var currentFolder: Bookmarkv2? - var currentFolder: Bookmarkv2? /// Certain bookmark actions are different in private browsing mode. - let isPrivateBrowsing: Bool + private let isPrivateBrowsing: Bool private var isAtBookmarkRootLevel: Bool { return self.currentFolder == nil @@ -62,6 +98,14 @@ class BookmarksViewController: SiteTableViewController, ToolbarUrlActionsProtoco private let importExportUtility = BraveCoreImportExportUtility() private var documentInteractionController: UIDocumentInteractionController? + private var searchBookmarksTimer: Timer? + private var isBookmarksBeingSearched = false + private let bookmarksSearchController = UISearchController(searchResultsController: nil) + private var bookmarksSearchQuery = "" + private lazy var noSearchResultOverlayView = createNoSearchResultOverlayView() + + // MARK: Lifecycle + init(folder: Bookmarkv2?, bookmarkManager: BookmarkManager, isPrivateBrowsing: Bool) { self.isPrivateBrowsing = isPrivateBrowsing self.bookmarkManager = bookmarkManager @@ -80,45 +124,73 @@ class BookmarksViewController: SiteTableViewController, ToolbarUrlActionsProtoco override func viewDidLoad() { super.viewDidLoad() - navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .done, target: self, action: #selector(tappedDone)) - - tableView.allowsSelectionDuringEditing = true - tableView.register(BookmarkTableViewCell.self, - forCellReuseIdentifier: String(describing: BookmarkTableViewCell.self)) - + applyTheme() setUpToolbar() updateEditBookmarksButtonStatus() updatedFolderHierarchy() } - @objc private func tappedDone() { - dismiss(animated: true) - } - - private func updateEditBookmarksButtonStatus() { - guard let objectsCount = bookmarksFRC?.fetchedObjectsCount else { return } + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) - editBookmarksButton?.isEnabled = objectsCount != 0 - if tableView.isEditing && objectsCount == 0 { - disableTableEditingMode() + view.addSubview(spinner) + spinner.snp.makeConstraints { + $0.center.equalTo(self.view.snp.center) } + spinner.startAnimating() + spinner.isHidden = false + updateLastVisitedFolder(currentFolder) + + bookmarkManager.waitForBookmarkModelLoaded({ [weak self] in + guard let self = self else { return } + + self.navigationController?.setToolbarHidden(false, animated: true) + self.reloadData() + self.switchTableEditingMode(true) + self.spinner.stopAnimating() + self.spinner.removeFromSuperview() + self.updateLastVisitedFolder(self.currentFolder) + }) } - private var leftToolbarItems: [UIBarButtonItem?] { - var items: [UIBarButtonItem?] = [.fixedSpace(5)] - if currentFolder == nil { - items.append(importExportButton) - - // Unlike Chromium, old CoreData implementation did not have permanent folders - if !Preferences.Chromium.syncV2BookmarksMigrationCompleted.value { - items.append(.fixedSpace(16)) - items.append(addFolderButton) - } - } else { - items.append(addFolderButton) + override func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + // Make sure to remove fetch results controller when view disappears. + // Otherwise, it may result in crash if a user is in a nested folder and + // sync changes happen. + bookmarksFRC = nil + } + + // MARK: Layout - Theme + + private func applyTheme() { + bookmarksSearchController.do { + $0.searchBar.autocapitalizationType = .none + $0.searchResultsUpdater = self + $0.obscuresBackgroundDuringPresentation = false + $0.searchBar.placeholder = Strings.searchBookmarksTitle + $0.delegate = self + $0.hidesNavigationBarDuringPresentation = true } - return items + navigationItem.do { + $0.searchController = bookmarksSearchController + $0.hidesSearchBarWhenScrolling = false + $0.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .done, target: self, action: #selector(tappedDone)) + } + + navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .done, target: self, action: #selector(tappedDone)) + + tableView.do { + $0.allowsSelectionDuringEditing = true + $0.register(BookmarkTableViewCell.self, + forCellReuseIdentifier: String(describing: BookmarkTableViewCell.self)) + } + } + + override func accessibilityPerformEscape() -> Bool { + dismiss(animated: true) + return true } private func setUpToolbar() { @@ -132,6 +204,15 @@ class BookmarksViewController: SiteTableViewController, ToolbarUrlActionsProtoco setToolbarItems(items, animated: true) } + private func updateEditBookmarksButtonStatus() { + guard let objectsCount = bookmarksFRC?.fetchedObjectsCount else { return } + + editBookmarksButton?.isEnabled = objectsCount != 0 + if tableView.isEditing && objectsCount == 0 { + disableTableEditingMode() + } + } + private func updatedFolderHierarchy() { DispatchQueue.main.async { guard let navigationController = self.navigationController else { return } @@ -152,82 +233,58 @@ class BookmarksViewController: SiteTableViewController, ToolbarUrlActionsProtoco Preferences.Chromium.lastBookmarksFolderNodeId.value = folder?.objectID ?? -1 } - override func reloadData() { - do { - // Recreate the frc if it was previously removed - // (when user navigated into a nested folder for example) - if bookmarksFRC == nil { - bookmarksFRC = bookmarkManager.frc(parent: currentFolder) - bookmarksFRC?.delegate = self - } - try self.bookmarksFRC?.performFetch() - } catch let error as NSError { - log.error(error.description) + private func createNoSearchResultOverlayView() -> UIView { + let overlayView = UIView().then { + $0.backgroundColor = .secondaryBraveBackground } - super.reloadData() - } - - override func viewWillAppear(_ animated: Bool) { - super.viewWillAppear(animated) - - view.addSubview(spinner) - spinner.snp.makeConstraints { - $0.center.equalTo(self.view.snp.center) + let welcomeLabel = UILabel().then { + $0.text = Strings.noSearchResultsfound + $0.textAlignment = .center + $0.font = DynamicFontHelper.defaultHelper.DeviceFontLight + $0.textColor = .braveLabel + $0.numberOfLines = 0 + $0.adjustsFontSizeToFitWidth = true } - spinner.startAnimating() - spinner.isHidden = false - updateLastVisitedFolder(currentFolder) - bookmarkManager.waitForBookmarkModelLoaded({ [weak self] in - guard let self = self else { return } - - self.navigationController?.setToolbarHidden(false, animated: true) - self.reloadData() - self.switchTableEditingMode(true) - self.spinner.stopAnimating() - self.spinner.removeFromSuperview() - self.updateLastVisitedFolder(self.currentFolder) - }) - } - - override func viewDidDisappear(_ animated: Bool) { - super.viewDidDisappear(animated) - // Make sure to remove fetch results controller when view disappears. - // Otherwise, it may result in crash if a user is in a nested folder and - // sync changes happen. - bookmarksFRC = nil - } - - func disableTableEditingMode() { - switchTableEditingMode(true) - } - - func switchTableEditingMode(_ forceOff: Bool = false) { - let editMode: Bool = forceOff ? false : !tableView.isEditing - tableView.setEditing(editMode, animated: forceOff ? false : true) + overlayView.addSubview(welcomeLabel) - updateEditBookmarksButton(editMode) - resetCellLongpressGesture(tableView.isEditing) + welcomeLabel.snp.makeConstraints { make in + make.centerX.equalTo(overlayView) + // Sets proper top constraint for iPhone 6 in portait and for iPad. + make.centerY.equalTo(overlayView).offset(-180).priority(100) + // Sets proper top constraint for iPhone 4, 5 in portrait. + make.top.greaterThanOrEqualTo(overlayView).offset(50) + make.width.equalTo(170) + } - editBookmarksButton?.isEnabled = bookmarksFRC?.fetchedObjectsCount != 0 - addFolderButton?.isEnabled = !editMode + return overlayView } - func updateEditBookmarksButton(_ tableIsEditing: Bool) { - self.editBookmarksButton?.title = tableIsEditing ? Strings.done : Strings.edit - self.editBookmarksButton?.style = tableIsEditing ? .done : .plain + private func updateEmptyPanelState() { + if isBookmarksBeingSearched, bookmarkManager.fetchedSearchObjectsCount == 0 { + showEmptyPanelState() + } else { + noSearchResultOverlayView.removeFromSuperview() + } } - func resetCellLongpressGesture(_ editing: Bool) { - for cell in self.tableView.visibleCells { - cell.gestureRecognizers?.forEach { cell.removeGestureRecognizer($0) } - if !editing { - cell.addGestureRecognizer(UILongPressGestureRecognizer(target: self, action: #selector(longPressedCell(_:)))) + private func showEmptyPanelState() { + if noSearchResultOverlayView.superview == nil { + view.addSubview(noSearchResultOverlayView) + view.bringSubviewToFront(noSearchResultOverlayView) + noSearchResultOverlayView.snp.makeConstraints { make -> Void in + make.edges.equalTo(tableView) } } } + // MARK: Actions + + @objc private func tappedDone() { + dismiss(animated: true) + } + @objc private func onAddBookmarksFolderButton() { let alert = UIAlertController.userTextInputAlert(title: Strings.newFolder, message: Strings.enterFolderName) { input, _ in @@ -261,36 +318,151 @@ class BookmarksViewController: SiteTableViewController, ToolbarUrlActionsProtoco present(alert, animated: true) } - func addFolder(titled title: String) { - bookmarkManager.addFolder(title: title, parentFolder: currentFolder) - tableView.setContentOffset(CGPoint.zero, animated: true) - } - @objc private func onEditBookmarksButton() { switchTableEditingMode() } - func tableView(_ tableView: UITableView, moveRowAt sourceIndexPath: IndexPath, to destinationIndexPath: IndexPath) { - guard let bookmarksFRC = bookmarksFRC else { + @objc private func longPressedCell(_ gesture: UILongPressGestureRecognizer) { + guard gesture.state == .began, + let cell = gesture.view as? UITableViewCell, + let indexPath = tableView.indexPath(for: cell), + let bookmark = fetchBookmarkItem(at: indexPath) else { return } - - bookmarkManager.reorderBookmarks(frc: bookmarksFRC, sourceIndexPath: sourceIndexPath, destinationIndexPath: destinationIndexPath) + + presentLongPressActions(gesture, urlString: bookmark.url, isPrivateBrowsing: isPrivateBrowsing, + customActions: bookmark.isFolder ? folderLongPressActions(bookmark) : nil) } - func tableView(_ tableView: UITableView, canMoveRowAt indexPath: IndexPath) -> Bool { - return true + private func folderLongPressActions(_ folder: Bookmarkv2) -> [UIAlertAction] { + let children = bookmarkManager.getChildren(forFolder: folder, includeFolders: false) ?? [] + + let urls: [URL] = children.compactMap { b in + guard let url = b.url else { return nil } + return URL(string: url) + } + + return [ + UIAlertAction( + title: String(format: Strings.openAllBookmarks, children.count), + style: .default, + handler: { [weak self] _ in + self?.toolbarUrlActionsDelegate?.batchOpen(urls) + self?.presentingViewController?.dismiss(animated: true) + } + ) + ] } - fileprivate func configureCell(_ cell: BookmarkTableViewCell, atIndexPath indexPath: IndexPath) { - // Make sure Bookmark at index path exists, - // `frc.object(at:)` crashes otherwise, doesn't fail safely with nil - if let objectsCount = bookmarksFRC?.fetchedObjectsCount, indexPath.row >= objectsCount { - assertionFailure("Bookmarks FRC index out of bounds") + // MARK: Data Fetch + + override func reloadData() { + performBookmarkFetch() + super.reloadData() + } + + private func performBookmarkFetch() { + if isBookmarksBeingSearched { + return + } + + do { + // Recreate the frc if it was previously removed + // (when user navigated into a nested folder for example) + if bookmarksFRC == nil { + bookmarksFRC = bookmarkManager.frc(parent: currentFolder) + bookmarksFRC?.delegate = self + } + try self.bookmarksFRC?.performFetch() + } catch let error as NSError { + log.error(error.description) + } + } + + private func refreshBookmarkSearchResult(with query: String = "") { + guard isBookmarksBeingSearched else { return } - guard let item = bookmarksFRC?.object(at: indexPath) else { return } + isLoading = true + + fetchBookmarks(with: query) { [weak self] in + self?.isLoading = false + } + } + + private func fetchBookmarks(with query: String, _ completion: @escaping () -> Void) { + bookmarkManager.fetchBookmarks(with: query) { [weak self] in + guard let self = self else { return } + + self.tableView.reloadData() + self.updateEmptyPanelState() + + completion() + } + } + + private func fetchBookmarkItem(at indexPath: IndexPath) -> Bookmarkv2? { + if isBookmarksBeingSearched { + return bookmarkManager.searchObject(at: indexPath) + } else { + return bookmarksFRC?.object(at: indexPath) + } + } + + //MARK: Internal + + private func disableTableEditingMode() { + switchTableEditingMode(true) + } + + private func switchTableEditingMode(_ forceOff: Bool = false) { + let editMode: Bool = forceOff ? false : !tableView.isEditing + tableView.setEditing(editMode, animated: forceOff ? false : true) + + updateEditBookmarksButton(editMode) + resetCellLongpressGesture(tableView.isEditing) + + editBookmarksButton?.isEnabled = bookmarksFRC?.fetchedObjectsCount != 0 + addFolderButton?.isEnabled = !editMode + } + + private func updateEditBookmarksButton(_ tableIsEditing: Bool) { + self.editBookmarksButton?.title = tableIsEditing ? Strings.done : Strings.edit + self.editBookmarksButton?.style = tableIsEditing ? .done : .plain + } + + private func resetCellLongpressGesture(_ editing: Bool) { + for cell in self.tableView.visibleCells { + cell.gestureRecognizers?.forEach { cell.removeGestureRecognizer($0) } + if !editing { + cell.addGestureRecognizer(UILongPressGestureRecognizer(target: self, action: #selector(longPressedCell(_:)))) + } + } + } + + private func addFolder(titled title: String) { + bookmarkManager.addFolder(title: title, parentFolder: currentFolder) + tableView.setContentOffset(CGPoint.zero, animated: true) + } + + fileprivate func configureCell(_ cell: BookmarkTableViewCell, atIndexPath indexPath: IndexPath) { + var fetchedBookmarkItem: Bookmarkv2? + + if isBookmarksBeingSearched { + fetchedBookmarkItem = bookmarkManager.searchObject(at: indexPath) + } else { + // Make sure Bookmark at index path exists, + // `frc.object(at:)` crashes otherwise, doesn't fail safely with nil + if let objectsCount = bookmarksFRC?.fetchedObjectsCount, indexPath.row >= objectsCount { + assertionFailure("Bookmarks FRC index out of bounds") + return + } + + fetchedBookmarkItem = bookmarksFRC?.object(at: indexPath) + } + + guard let item = fetchedBookmarkItem else { return } cell.tag = item.objectID // See if the cell holds the same bookmark. If yes, we do not have to recreate its image view @@ -383,59 +555,23 @@ class BookmarksViewController: SiteTableViewController, ToolbarUrlActionsProtoco cell.setRightBadge(nil) } } - - @objc private func longPressedCell(_ gesture: UILongPressGestureRecognizer) { - guard gesture.state == .began, - let cell = gesture.view as? UITableViewCell, - let indexPath = tableView.indexPath(for: cell), - let bookmark = bookmarksFRC?.object(at: indexPath) else { - return - } - presentLongPressActions(gesture, urlString: bookmark.url, isPrivateBrowsing: isPrivateBrowsing, - customActions: bookmark.isFolder ? folderLongPressActions(bookmark) : nil) - } - - private func folderLongPressActions(_ folder: Bookmarkv2) -> [UIAlertAction] { - let children = bookmarkManager.getChildren(forFolder: folder, includeFolders: false) ?? [] - - let urls: [URL] = children.compactMap { b in - guard let url = b.url else { return nil } - return URL(string: url) - } - - return [ - UIAlertAction( - title: String(format: Strings.openAllBookmarks, children.count), - style: .default, - handler: { [weak self] _ in - self?.toolbarUrlActionsDelegate?.batchOpen(urls) - self?.presentingViewController?.dismiss(animated: true) - } - ) - ] + override func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat { + 0 } override func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { - return nil - } - - override func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { - return super.tableView(tableView, heightForRowAt: indexPath) - } - - override func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat { - return 0 + nil } func tableView(_ tableView: UITableView, willSelectRowAt indexPath: IndexPath) -> IndexPath? { - return indexPath + indexPath } func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { tableView.deselectRow(at: indexPath, animated: false) - guard let bookmark = bookmarksFRC?.object(at: indexPath) else { return } + guard let bookmark = fetchBookmarkItem(at: indexPath) else { return } if !bookmark.isFolder { if tableView.isEditing { @@ -475,16 +611,8 @@ class BookmarksViewController: SiteTableViewController, ToolbarUrlActionsProtoco } } - func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCell.EditingStyle, forRowAt indexPath: IndexPath) { - // Intentionally blank. Required to use UITableViewRowActions - } - - func tableView(_ tableView: UITableView, editingStyleForRowAt indexPath: IndexPath) -> UITableViewCell.EditingStyle { - return .delete - } - override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - return bookmarksFRC?.fetchedObjectsCount ?? 0 + isBookmarksBeingSearched ? bookmarkManager.fetchedSearchObjectsCount : bookmarksFRC?.fetchedObjectsCount ?? 0 } override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { @@ -499,13 +627,31 @@ class BookmarksViewController: SiteTableViewController, ToolbarUrlActionsProtoco } override func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool { - guard let item = bookmarksFRC?.object(at: indexPath) else { return false } - return item.canBeDeleted + var fetchedBookmarkItem: Bookmarkv2? + if isBookmarksBeingSearched { + return true + } else { + fetchedBookmarkItem = bookmarksFRC?.object(at: indexPath) + return fetchedBookmarkItem?.canBeDeleted ?? false + + } + } +} + +// MARK: UITableViewDelegate - Editing + +extension BookmarksViewController { + + func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCell.EditingStyle, forRowAt indexPath: IndexPath) { + // Intentionally blank. Required to use UITableViewRowActions + } + + func tableView(_ tableView: UITableView, editingStyleForRowAt indexPath: IndexPath) -> UITableViewCell.EditingStyle { + .delete } func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? { - guard let item = bookmarksFRC?.object(at: indexPath), - item.canBeDeleted else { return nil } + guard let item = fetchBookmarkItem(at: indexPath), item.canBeDeleted else { return nil } let deleteAction = UIContextualAction(style: .destructive, title: Strings.delete) { [weak self] _, _, completion in guard let self = self else { @@ -538,7 +684,19 @@ class BookmarksViewController: SiteTableViewController, ToolbarUrlActionsProtoco return UISwipeActionsConfiguration(actions: [deleteAction, editAction]) } - fileprivate func showEditBookmarkController(bookmark: Bookmarkv2) { + func tableView(_ tableView: UITableView, moveRowAt sourceIndexPath: IndexPath, to destinationIndexPath: IndexPath) { + guard let bookmarksFRC = bookmarksFRC else { + return + } + + bookmarkManager.reorderBookmarks(frc: bookmarksFRC, sourceIndexPath: sourceIndexPath, destinationIndexPath: destinationIndexPath) + } + + func tableView(_ tableView: UITableView, canMoveRowAt indexPath: IndexPath) -> Bool { + !isBookmarksBeingSearched + } + + private func showEditBookmarkController(bookmark: Bookmarkv2) { self.isEditingIndividualBookmark = true var mode: BookmarkEditMode? @@ -554,13 +712,12 @@ class BookmarksViewController: SiteTableViewController, ToolbarUrlActionsProtoco } } - override func accessibilityPerformEscape() -> Bool { - dismiss(animated: true) - return true - } } +// MARK: BookmarksV2FetchResultsDelegate + extension BookmarksViewController: BookmarksV2FetchResultsDelegate { + func controllerWillChangeContent(_ controller: BookmarksV2FetchResultsController) { tableView.beginUpdates() } @@ -606,6 +763,11 @@ extension BookmarksViewController: BookmarksV2FetchResultsDelegate { } func controllerDidReloadContents(_ controller: BookmarksV2FetchResultsController) { + if isBookmarksBeingSearched { + refreshBookmarkSearchResult(with: bookmarksSearchQuery) + return + } + // We're in some sort of invalid state in sync.. // Somehow this folder was deleted but the user is currently viewing it.. // Might be a good idea to let the user know in the future that the folder they are currently viewing @@ -623,7 +785,10 @@ extension BookmarksViewController: BookmarksV2FetchResultsDelegate { } } +// MARK: UIDocumentPickerDelegate - UIDocumentInteractionControllerDelegate + extension BookmarksViewController: UIDocumentPickerDelegate, UIDocumentInteractionControllerDelegate { + func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) { guard let url = urls.first, urls.count == 1 else { return @@ -661,24 +826,21 @@ extension BookmarksViewController: UIDocumentPickerDelegate, UIDocumentInteracti } } +// MARK: Export-Import Bookmarks + extension BookmarksViewController { + func importBookmarks(from url: URL) { - self.view.addSubview(spinner) - spinner.snp.makeConstraints { - $0.center.equalTo(self.view.snp.center) - } - - spinner.startAnimating() - spinner.isHidden = false + isLoading = true self.importExportUtility.importBookmarks(from: url) { [weak self] success in guard let self = self else { return } - self.spinner.stopAnimating() - self.spinner.removeFromSuperview() + self.isLoading = false let alert = UIAlertController(title: Strings.Sync.bookmarksImportPopupErrorTitle, - message: success ? Strings.Sync.bookmarksImportPopupSuccessMessage : Strings.Sync.bookmarksImportPopupFailureMessage, + message: success ? Strings.Sync.bookmarksImportPopupSuccessMessage : + Strings.Sync.bookmarksImportPopupFailureMessage, preferredStyle: .alert) alert.addAction(UIAlertAction(title: Strings.OKString, style: .default, handler: nil)) self.present(alert, animated: true, completion: nil) @@ -686,19 +848,12 @@ extension BookmarksViewController { } func exportBookmarks(to url: URL) { - self.view.addSubview(spinner) - spinner.snp.makeConstraints { - $0.center.equalTo(self.view.snp.center) - } - - spinner.startAnimating() - spinner.isHidden = false + isLoading = true self.importExportUtility.exportBookmarks(to: url) { [weak self] success in guard let self = self else { return } - self.spinner.stopAnimating() - self.spinner.removeFromSuperview() + self.isLoading = false // Controller must be retained otherwise `AirDrop` and other sharing options will fail! self.documentInteractionController = UIDocumentInteractionController(url: url) @@ -711,4 +866,56 @@ extension BookmarksViewController { vc.presentOptionsMenu(from: importExportButton, animated: true) } } + +} + +// MARK: UISearchResultUpdating + +extension BookmarksViewController: UISearchResultsUpdating { + + func updateSearchResults(for searchController: UISearchController) { + guard let query = searchController.searchBar.text else { return } + + if searchBookmarksTimer != nil { + searchBookmarksTimer?.invalidate() + searchBookmarksTimer = nil + } + + searchBookmarksTimer = + Timer.scheduledTimer(timeInterval: 0.1, target: self, selector: #selector(fetchSearchResults(timer:)), userInfo: query, repeats: false) + } + + @objc private func fetchSearchResults(timer: Timer) { + guard let query = timer.userInfo as? String else { + bookmarksSearchQuery = "" + return + } + + bookmarksSearchQuery = query + refreshBookmarkSearchResult(with: bookmarksSearchQuery) + } +} + +// MARK: UISearchControllerDelegate + +extension BookmarksViewController: UISearchControllerDelegate { + + func willPresentSearchController(_ searchController: UISearchController) { + isBookmarksBeingSearched = true + bookmarksSearchQuery = "" + tableView.setEditing(false, animated: true) + tableView.reloadData() + + // Bottom toolbar needs to be hidden when searching for bookmarks + navigationController?.setToolbarHidden(true, animated: true) + } + + func willDismissSearchController(_ searchController: UISearchController) { + isBookmarksBeingSearched = false + updateEmptyPanelState() + reloadData() + + // Re-enable bottom var options when search is done + navigationController?.setToolbarHidden(false, animated: true) + } } diff --git a/Client/Frontend/Sync/BraveCore/Bookmarks/BookmarkManager.swift b/Client/Frontend/Sync/BraveCore/Bookmarks/BookmarkManager.swift index 0e49267f212..15322a251b9 100644 --- a/Client/Frontend/Sync/BraveCore/Bookmarks/BookmarkManager.swift +++ b/Client/Frontend/Sync/BraveCore/Bookmarks/BookmarkManager.swift @@ -24,6 +24,10 @@ class BookmarkManager { // MARK: Internal public static var rootNodeId: String? + + public var fetchedSearchObjectsCount: Int { + searchBookmarkList.count + } // Returns the last visited folder // If no folder was visited, returns the mobile bookmarks folder @@ -184,6 +188,22 @@ class BookmarkManager { }) } + public func fetchBookmarks(with query: String = "", _ completion: @escaping () -> Void) { + guard let bookmarksAPI = bookmarksAPI else { + self.searchBookmarkList = [] + completion() + return + } + + bookmarksAPI.search(withQuery: query, maxCount: 200, completion: { [weak self] nodes in + guard let self = self else { return } + + self.searchBookmarkList = nodes.compactMap({ return !$0.isFolder ? Bookmarkv2($0) : nil }) + + completion() + }) + } + public func reorderBookmarks(frc: BookmarksV2FetchResultsController?, sourceIndexPath: IndexPath, destinationIndexPath: IndexPath) { guard let frc = frc, let bookmarksAPI = bookmarksAPI else { return @@ -263,10 +283,16 @@ class BookmarkManager { bookmarkItem.bookmarkFavIconObserver = bookmarksAPI.add(observer) } + public func searchObject(at indexPath: IndexPath) -> Bookmarkv2? { + searchBookmarkList[safe: indexPath.row] + } + // MARK: Private private var observer: BookmarkModelListener? private let bookmarksAPI: BraveBookmarksAPI? + // The list of bookmarks that are listed in search result + private var searchBookmarkList: [Bookmarkv2] = [] private func removeFavIconObserver(_ bookmarkItem: Bookmarkv2) { bookmarkItem.bookmarkFavIconObserver = nil