Dactyloidae/mobile/ios/Client/Frontend/Home/BookmarksPanel.swift
2026-06-26 21:04:09 -07:00

593 lines
25 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import UIKit
import Storage
import Shared
import XCGLogger
private let log = Logger.browserLogger
let BookmarkStatusChangedNotification = "BookmarkStatusChangedNotification"
// MARK: - Placeholder strings for Bug 1232810.
let deleteWarningTitle = NSLocalizedString("This folder isnt empty.", tableName: "BookmarkPanelDeleteConfirm", comment: "Title of the confirmation alert when the user tries to delete a folder that still contains bookmarks and/or folders.")
let deleteWarningDescription = NSLocalizedString("Are you sure you want to delete it and its contents?", tableName: "BookmarkPanelDeleteConfirm", comment: "Main body of the confirmation alert when the user tries to delete a folder that still contains bookmarks and/or folders.")
let deleteCancelButtonLabel = NSLocalizedString("Cancel", tableName: "BookmarkPanelDeleteConfirm", comment: "Button label to cancel deletion when the user tried to delete a non-empty folder.")
let deleteDeleteButtonLabel = NSLocalizedString("Delete", tableName: "BookmarkPanelDeleteConfirm", comment: "Button label for the button that deletes a folder and all of its children.")
// Placeholder strings for Bug 1248034
let emptyBookmarksText = NSLocalizedString("Bookmarks you save will show up here.", comment: "Status label for the empty Bookmarks state.")
// MARK: - UX constants.
struct BookmarksPanelUX {
static let BookmarkFolderHeaderViewChevronInset: CGFloat = 10
static let BookmarkFolderChevronSize: CGFloat = 20
static let BookmarkFolderChevronLineWidth: CGFloat = 2.0
static let BookmarkFolderTextColor = UIColor(red: 92/255, green: 92/255, blue: 92/255, alpha: 1.0)
static let BookmarkFolderBGColor = UIColor(rgb: 0xf7f8f7).withAlphaComponent(0.3)
static let WelcomeScreenPadding: CGFloat = 15
static let WelcomeScreenItemTextColor = UIColor.gray
static let WelcomeScreenItemWidth = 170
static let SeparatorRowHeight: CGFloat = 0.5
static let IconSize: CGFloat = 23
static let IconBorderColor = UIColor(white: 0, alpha: 0.1)
static let IconBorderWidth: CGFloat = 0.5
}
class BookmarksPanel: SiteTableViewController, HomePanel {
weak var homePanelDelegate: HomePanelDelegate?
var source: BookmarksModel?
var parentFolders = [BookmarkFolder]()
var bookmarkFolder: BookmarkFolder?
var refreshControl: UIRefreshControl?
fileprivate lazy var longPressRecognizer: UILongPressGestureRecognizer = {
return UILongPressGestureRecognizer(target: self, action: #selector(BookmarksPanel.longPress(_:)))
}()
fileprivate lazy var emptyStateOverlayView: UIView = self.createEmptyStateOverlayView()
fileprivate let BookmarkFolderCellIdentifier = "BookmarkFolderIdentifier"
fileprivate let BookmarkSeparatorCellIdentifier = "BookmarkSeparatorIdentifier"
fileprivate let BookmarkFolderHeaderViewIdentifier = "BookmarkFolderHeaderIdentifier"
init() {
super.init(nibName: nil, bundle: nil)
NotificationCenter.default.addObserver(self, selector: #selector(BookmarksPanel.notificationReceived(_:)), name: NotificationFirefoxAccountChanged, object: nil)
self.tableView.register(SeparatorTableCell.self, forCellReuseIdentifier: BookmarkSeparatorCellIdentifier)
self.tableView.register(BookmarkFolderTableViewCell.self, forCellReuseIdentifier: BookmarkFolderCellIdentifier)
self.tableView.register(BookmarkFolderTableViewHeader.self, forHeaderFooterViewReuseIdentifier: BookmarkFolderHeaderViewIdentifier)
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
tableView.addGestureRecognizer(longPressRecognizer)
self.tableView.accessibilityIdentifier = "Bookmarks List"
self.refreshControl = UIRefreshControl()
self.tableView.addSubview(refreshControl!)
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
refreshControl?.addTarget(self, action: #selector(BookmarksPanel.refreshBookmarks), for: .valueChanged)
loadData()
}
override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
refreshControl?.removeTarget(self, action: #selector(BookmarksPanel.refreshBookmarks), for: .valueChanged)
}
func loadData() {
// If we've not already set a source for this panel, fetch a new model from
// the root; otherwise, just use the existing source to select a folder.
guard let source = self.source else {
// Get all the bookmarks split by folders
if let bookmarkFolder = bookmarkFolder {
profile.bookmarks.modelFactory >>== { $0.modelForFolder(bookmarkFolder).upon(self.onModelFetched) }
} else {
profile.bookmarks.modelFactory >>== { $0.modelForRoot().upon(self.onModelFetched) }
}
return
}
if let bookmarkFolder = bookmarkFolder {
source.selectFolder(bookmarkFolder).upon(onModelFetched)
} else {
source.selectFolder(BookmarkRoots.MobileFolderGUID).upon(onModelFetched)
}
}
func notificationReceived(_ notification: Notification) {
switch notification.name {
case NotificationFirefoxAccountChanged:
self.reloadData()
break
default:
// no need to do anything at all
log.warning("Received unexpected notification \(notification.name)")
break
}
}
@objc fileprivate func refreshBookmarks() {
profile.syncManager.mirrorBookmarks().upon { (_) in
DispatchQueue.main.async {
self.loadData()
self.refreshControl?.endRefreshing()
}
}
}
fileprivate func createEmptyStateOverlayView() -> UIView {
let overlayView = UIView()
overlayView.backgroundColor = UIColor.white
let logoImageView = UIImageView(image: UIImage(named: "emptyBookmarks"))
overlayView.addSubview(logoImageView)
logoImageView.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(HomePanelUX.EmptyTabContentOffset).priority(100)
// Sets proper top constraint for iPhone 4, 5 in portrait.
make.top.greaterThanOrEqualTo(overlayView).offset(50)
}
let welcomeLabel = UILabel()
overlayView.addSubview(welcomeLabel)
welcomeLabel.text = emptyBookmarksText
welcomeLabel.textAlignment = NSTextAlignment.center
welcomeLabel.font = DynamicFontHelper.defaultHelper.DeviceFontLight
welcomeLabel.textColor = BookmarksPanelUX.WelcomeScreenItemTextColor
welcomeLabel.numberOfLines = 0
welcomeLabel.adjustsFontSizeToFitWidth = true
welcomeLabel.snp.makeConstraints { make in
make.centerX.equalTo(overlayView)
make.top.equalTo(logoImageView.snp.bottom).offset(BookmarksPanelUX.WelcomeScreenPadding)
make.width.equalTo(BookmarksPanelUX.WelcomeScreenItemWidth)
}
return overlayView
}
fileprivate func updateEmptyPanelState() {
if source?.current.count == 0 && source?.current.guid == BookmarkRoots.MobileFolderGUID {
if self.emptyStateOverlayView.superview == nil {
self.view.addSubview(self.emptyStateOverlayView)
self.view.bringSubview(toFront: self.emptyStateOverlayView)
self.emptyStateOverlayView.snp.makeConstraints { make -> Void in
make.edges.equalTo(self.tableView)
}
}
} else {
self.emptyStateOverlayView.removeFromSuperview()
}
}
fileprivate func onModelFetched(_ result: Maybe<BookmarksModel>) {
guard let model = result.successValue else {
self.onModelFailure(result.failureValue as Any)
return
}
self.onNewModel(model)
}
fileprivate func onNewModel(_ model: BookmarksModel) {
if Thread.current.isMainThread {
self.source = model
self.tableView.reloadData()
return
}
DispatchQueue.main.async {
self.source = model
self.tableView.reloadData()
self.updateEmptyPanelState()
}
}
fileprivate func onModelFailure(_ e: Any) {
log.error("Error: failed to get data: \(e)")
}
override func reloadData() {
self.source?.reloadData().upon(onModelFetched)
}
@objc fileprivate func longPress(_ longPressGestureRecognizer: UILongPressGestureRecognizer) {
guard longPressGestureRecognizer.state == UIGestureRecognizerState.began else { return }
let touchPoint = longPressGestureRecognizer.location(in: tableView)
guard let indexPath = tableView.indexPathForRow(at: touchPoint) else { return }
presentContextMenu(for: indexPath)
}
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return source?.current.count ?? 0
}
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
guard let source = source, let bookmark = source.current[indexPath.row] else { return super.tableView(tableView, cellForRowAt: indexPath) }
switch bookmark {
case let item as BookmarkItem:
let cell = super.tableView(tableView, cellForRowAt: indexPath)
if item.title.isEmpty {
cell.textLabel?.text = item.url
} else {
cell.textLabel?.text = item.title
}
if let url = bookmark.favicon?.url.asURL, url.scheme == "asset" {
cell.imageView?.image = UIImage(named: url.host!)
} else {
cell.imageView?.layer.borderColor = BookmarksPanelUX.IconBorderColor.cgColor
cell.imageView?.layer.borderWidth = BookmarksPanelUX.IconBorderWidth
let bookmarkURL = URL(string: item.url)
cell.imageView?.setIcon(bookmark.favicon, forURL: bookmarkURL, completed: { (color, url) in
if bookmarkURL == url {
cell.imageView?.image = cell.imageView?.image?.createScaled(CGSize(width: BookmarksPanelUX.IconSize, height: BookmarksPanelUX.IconSize))
cell.imageView?.backgroundColor = color
cell.imageView?.contentMode = .center
}
})
}
return cell
case is BookmarkSeparator:
return tableView.dequeueReusableCell(withIdentifier: BookmarkSeparatorCellIdentifier, for: indexPath)
case let bookmark as BookmarkFolder:
let cell = tableView.dequeueReusableCell(withIdentifier: BookmarkFolderCellIdentifier, for: indexPath)
cell.textLabel?.text = bookmark.title
return cell
default:
// This should never happen.
return super.tableView(tableView, cellForRowAt: indexPath)
}
}
func tableView(_ tableView: UITableView, willDisplayCell cell: UITableViewCell, forRowAtIndexPath indexPath: IndexPath) {
if let cell = cell as? BookmarkFolderTableViewCell {
cell.textLabel?.font = DynamicFontHelper.defaultHelper.DeviceFontHistoryPanel
}
}
override func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? {
// Don't show a header for the root
if source == nil || parentFolders.isEmpty {
return nil
}
guard let header = tableView.dequeueReusableHeaderFooterView(withIdentifier: BookmarkFolderHeaderViewIdentifier) as? BookmarkFolderTableViewHeader else { return nil }
// register as delegate to ensure we get notified when the user interacts with this header
if header.delegate == nil {
header.delegate = self
}
if parentFolders.count == 1 {
header.textLabel?.text = NSLocalizedString("Bookmarks", comment: "Panel accessibility label")
} else if let parentFolder = parentFolders.last {
header.textLabel?.text = parentFolder.title
}
return header
}
override func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
if let it = self.source?.current[indexPath.row], it is BookmarkSeparator {
return BookmarksPanelUX.SeparatorRowHeight
}
return super.tableView(tableView, heightForRowAt: indexPath)
}
override func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat {
// Don't show a header for the root. If there's no root (i.e. source == nil), we'll also show no header.
if source == nil || parentFolders.isEmpty {
return 0
}
return SiteTableViewControllerUX.RowHeight
}
func tableView(_ tableView: UITableView, willDisplayHeaderView view: UIView, forSection section: Int) {
if let header = view as? BookmarkFolderTableViewHeader {
// for some reason specifying the font in header view init is being ignored, so setting it here
header.textLabel?.font = DynamicFontHelper.defaultHelper.DeviceFontHistoryPanel
}
}
override func tableView(_ tableView: UITableView, hasFullWidthSeparatorForRowAtIndexPath indexPath: IndexPath) -> Bool {
// Show a full-width border for cells above separators, so they don't have a weird step.
// Separators themselves already have a full-width border, but let's force the issue
// just in case.
let this = self.source?.current[indexPath.row]
if (indexPath.row + 1) < (self.source?.current.count)! {
let below = self.source?.current[indexPath.row + 1]
if this is BookmarkSeparator || below is BookmarkSeparator {
return true
}
}
return super.tableView(tableView, hasFullWidthSeparatorForRowAtIndexPath: indexPath)
}
func tableView(_ tableView: UITableView, didSelectRowAtIndexPath indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: false)
guard let source = source else {
return
}
let bookmark = source.current[indexPath.row]
switch bookmark {
case let item as BookmarkItem:
homePanelDelegate?.homePanel(self, didSelectURLString: item.url, visitType: VisitType.bookmark)
LeanPlumClient.shared.track(event: .openedBookmark)
UnifiedTelemetry.recordEvent(category: .action, method: .open, object: .bookmark, value: .bookmarksPanel)
break
case let folder as BookmarkFolder:
log.debug("Selected \(folder.guid)")
let nextController = BookmarksPanel()
nextController.parentFolders = parentFolders + [source.current]
nextController.bookmarkFolder = folder
nextController.homePanelDelegate = self.homePanelDelegate
nextController.profile = self.profile
source.modelFactory.uponQueue(DispatchQueue.main) { maybe in
guard let factory = maybe.successValue else {
// Nothing we can do.
return
}
let specificFactory = factory.factoryForIndex(indexPath.row, inFolder: source.current)
nextController.source = BookmarksModel(modelFactory: specificFactory, root: folder)
self.navigationController?.pushViewController(nextController, animated: true)
}
break
default:
// You can't do anything with separators.
break
}
}
func tableView(_ tableView: UITableView, commitEditingStyle editingStyle: UITableViewCellEditingStyle, forRowAtIndexPath indexPath: IndexPath) {
// Intentionally blank. Required to use UITableViewRowActions
}
private func editingStyleforRow(atIndexPath indexPath: IndexPath) -> UITableViewCellEditingStyle {
guard let source = source else {
return .none
}
if source.current[indexPath.row] is BookmarkSeparator {
// Because the deletion block is too big.
return .none
}
if source.current.itemIsEditableAtIndex(indexPath.row) {
return .delete
}
return .none
}
func tableView(_ tableView: UITableView, editingStyleForRowAtIndexPath indexPath: IndexPath) -> UITableViewCellEditingStyle {
return editingStyleforRow(atIndexPath: indexPath)
}
func tableView(_ tableView: UITableView, editActionsForRowAtIndexPath indexPath: IndexPath) -> [AnyObject]? {
let editingStyle = editingStyleforRow(atIndexPath: indexPath)
guard let source = self.source, editingStyle == .delete else {
return nil
}
let title = NSLocalizedString("Delete", tableName: "BookmarkPanel", comment: "Action button for deleting bookmarks in the bookmarks panel.")
let delete = UITableViewRowAction(style: UITableViewRowActionStyle.default, title: title, handler: { (action, indexPath) in
self.deleteBookmark(indexPath: indexPath, source: source)
UnifiedTelemetry.recordEvent(category: .action, method: .delete, object: .bookmark, value: .bookmarksPanel, extras: ["gesture": "swipe"])
})
return [delete]
}
func pinTopSite(_ site: Site) {
_ = profile.history.addPinnedTopSite(site).value
}
func deleteBookmark(indexPath: IndexPath, source: BookmarksModel) {
guard let bookmark = source.current[indexPath.row] else {
return
}
assert(!(bookmark is BookmarkFolder))
if bookmark is BookmarkFolder {
// TODO: check whether the folder is empty (excluding separators). If it isn't
// then we must ask the user to confirm. Bug 1232810.
log.debug("Not deleting folder.")
return
}
log.debug("Removing rows \(indexPath).")
// Block to do this -- this is UI code.
guard let factory = source.modelFactory.value.successValue else {
log.error("Couldn't get model factory. This is unexpected.")
self.onModelFailure(DatabaseError(description: "Unable to get factory."))
return
}
let specificFactory = factory.factoryForIndex(indexPath.row, inFolder: source.current)
if let err = specificFactory.removeByGUID(bookmark.guid).value.failureValue {
log.debug("Failed to remove \(bookmark.guid).")
self.onModelFailure(err)
return
}
self.tableView.beginUpdates()
self.source = source.removeGUIDFromCurrent(bookmark.guid)
self.tableView.deleteRows(at: [indexPath], with: UITableViewRowAnimation.left)
self.tableView.endUpdates()
self.updateEmptyPanelState()
NotificationCenter.default.post(name: NSNotification.Name(rawValue: BookmarkStatusChangedNotification), object: bookmark, userInfo: ["added": false]
)
}
}
extension BookmarksPanel: HomePanelContextMenu {
func presentContextMenu(for site: Site, with indexPath: IndexPath, completionHandler: @escaping () -> PhotonActionSheet?) {
guard let contextMenu = completionHandler() else { return }
self.present(contextMenu, animated: true, completion: nil)
}
func getSiteDetails(for indexPath: IndexPath) -> Site? {
guard let bookmarkItem = source?.current[indexPath.row] as? BookmarkItem else { return nil }
let site = Site(url: bookmarkItem.url, title: bookmarkItem.title, bookmarked: true, guid: bookmarkItem.guid)
site.icon = bookmarkItem.favicon
return site
}
func getContextMenuActions(for site: Site, with indexPath: IndexPath) -> [PhotonActionSheetItem]? {
guard var actions = getDefaultContextMenuActions(for: site, homePanelDelegate: homePanelDelegate) else { return nil }
let pinTopSite = PhotonActionSheetItem(title: Strings.PinTopsiteActionTitle, iconString: "action_pin", handler: { action in
self.pinTopSite(site)
})
actions.append(pinTopSite)
// Only local bookmarks can be removed
guard let source = source else { return nil }
if source.current.itemIsEditableAtIndex(indexPath.row) {
let removeAction = PhotonActionSheetItem(title: Strings.RemoveBookmarkContextMenuTitle, iconString: "action_bookmark_remove", handler: { action in
self.deleteBookmark(indexPath: indexPath, source: source)
UnifiedTelemetry.recordEvent(category: .action, method: .delete, object: .bookmark, value: .bookmarksPanel, extras: ["gesture": "long-press"])
})
actions.append(removeAction)
}
return actions
}
}
private protocol BookmarkFolderTableViewHeaderDelegate {
func didSelectHeader()
}
extension BookmarksPanel: BookmarkFolderTableViewHeaderDelegate {
fileprivate func didSelectHeader() {
_ = self.navigationController?.popViewController(animated: true)
}
}
class BookmarkFolderTableViewCell: TwoLineTableViewCell {
override init(style: UITableViewCellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
self.backgroundColor = BookmarksPanelUX.BookmarkFolderBGColor
textLabel?.backgroundColor = UIColor.clear
textLabel?.tintColor = BookmarksPanelUX.BookmarkFolderTextColor
imageView?.image = UIImage(named: "bookmarkFolder")
accessoryType = UITableViewCellAccessoryType.disclosureIndicator
separatorInset = UIEdgeInsets.zero
}
override func layoutSubviews() {
super.layoutSubviews()
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
fileprivate class BookmarkFolderTableViewHeader: UITableViewHeaderFooterView {
var delegate: BookmarkFolderTableViewHeaderDelegate?
lazy var titleLabel: UILabel = {
let label = UILabel()
label.textColor = UIConstants.HighlightBlue
return label
}()
lazy var chevron: ChevronView = {
let chevron = ChevronView(direction: .left)
chevron.tintColor = UIConstants.HighlightBlue
chevron.lineWidth = BookmarksPanelUX.BookmarkFolderChevronLineWidth
return chevron
}()
lazy var topBorder: UIView = {
let view = UIView()
view.backgroundColor = SiteTableViewControllerUX.HeaderBorderColor
return view
}()
lazy var bottomBorder: UIView = {
let view = UIView()
view.backgroundColor = SiteTableViewControllerUX.HeaderBorderColor
return view
}()
override var textLabel: UILabel? {
return titleLabel
}
override init(reuseIdentifier: String?) {
super.init(reuseIdentifier: reuseIdentifier)
isUserInteractionEnabled = true
let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(BookmarkFolderTableViewHeader.viewWasTapped(_:)))
tapGestureRecognizer.numberOfTapsRequired = 1
addGestureRecognizer(tapGestureRecognizer)
addSubview(topBorder)
addSubview(bottomBorder)
contentView.addSubview(chevron)
contentView.addSubview(titleLabel)
chevron.snp.makeConstraints { make in
make.left.equalTo(contentView).offset(BookmarksPanelUX.BookmarkFolderHeaderViewChevronInset)
make.centerY.equalTo(contentView)
make.size.equalTo(BookmarksPanelUX.BookmarkFolderChevronSize)
}
titleLabel.snp.makeConstraints { make in
make.left.equalTo(chevron.snp.right).offset(BookmarksPanelUX.BookmarkFolderHeaderViewChevronInset)
make.right.greaterThanOrEqualTo(contentView).offset(-BookmarksPanelUX.BookmarkFolderHeaderViewChevronInset)
make.centerY.equalTo(contentView)
}
topBorder.snp.makeConstraints { make in
make.left.right.equalTo(self)
make.top.equalTo(self).offset(-0.5)
make.height.equalTo(0.5)
}
bottomBorder.snp.makeConstraints { make in
make.left.right.bottom.equalTo(self)
make.height.equalTo(0.5)
}
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
@objc fileprivate func viewWasTapped(_ gestureRecognizer: UITapGestureRecognizer) {
delegate?.didSelectHeader()
}
}