Dactyloidae iOS initial commit

This commit is contained in:
wuggy 2026-06-26 21:04:09 -07:00
commit 7154a0497e
2123 changed files with 197052 additions and 0 deletions

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,558 @@
/* 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 Foundation
import Shared
import SDWebImage
import Storage
struct TopSiteCellUX {
static let TitleHeight: CGFloat = 20
static let TitleBackgroundColor = UIColor(colorLiteralRed: 1, green: 1, blue: 1, alpha: 0.7)
static let TitleTextColor = UIColor.black
static let TitleFont = DynamicFontHelper.defaultHelper.SmallSizeRegularWeightAS
static let SelectedOverlayColor = UIColor(white: 0.0, alpha: 0.25)
static let CellCornerRadius: CGFloat = 4
static let TitleOffset: CGFloat = 5
static let OverlayColor = UIColor(white: 0.0, alpha: 0.25)
static let IconSizePercent: CGFloat = 0.8
static let BorderColor = UIColor(white: 0, alpha: 0.1)
static let BorderWidth: CGFloat = 0.5
static let PinIconSize: CGFloat = 12
static let PinColor = UIColor(rgb: 0x272727)
}
/*
* The TopSite cell that appears in the ASHorizontalScrollView.
*/
class TopSiteItemCell: UICollectionViewCell {
var url: URL?
lazy var imageView: UIImageView = {
let imageView = UIImageView()
imageView.layer.masksToBounds = true
return imageView
}()
lazy var pinImageView: UIImageView = {
let imageView = UIImageView()
imageView.image = UIImage.templateImageNamed("pin_small")
imageView.tintColor = TopSiteCellUX.PinColor
return imageView
}()
lazy fileprivate var titleLabel: UILabel = {
let titleLabel = UILabel()
titleLabel.layer.masksToBounds = true
titleLabel.textAlignment = .center
titleLabel.font = TopSiteCellUX.TitleFont
titleLabel.textColor = TopSiteCellUX.TitleTextColor
titleLabel.backgroundColor = UIColor.clear
return titleLabel
}()
lazy private var faviconBG: UIView = {
let view = UIView()
view.layer.cornerRadius = TopSiteCellUX.CellCornerRadius
view.layer.masksToBounds = true
view.layer.borderWidth = TopSiteCellUX.BorderWidth
view.layer.borderColor = TopSiteCellUX.BorderColor.cgColor
return view
}()
lazy var selectedOverlay: UIView = {
let selectedOverlay = UIView()
selectedOverlay.backgroundColor = TopSiteCellUX.OverlayColor
selectedOverlay.isHidden = true
return selectedOverlay
}()
lazy var titleBorder: CALayer = {
let border = CALayer()
border.backgroundColor = TopSiteCellUX.BorderColor.cgColor
return border
}()
override var isSelected: Bool {
didSet {
self.selectedOverlay.isHidden = !isSelected
}
}
override init(frame: CGRect) {
super.init(frame: frame)
isAccessibilityElement = true
accessibilityIdentifier = "TopSite"
contentView.addSubview(titleLabel)
contentView.addSubview(faviconBG)
contentView.addSubview(imageView)
contentView.addSubview(selectedOverlay)
titleLabel.snp.makeConstraints { make in
make.left.equalTo(self).offset(TopSiteCellUX.TitleOffset)
make.right.equalTo(self).offset(-TopSiteCellUX.TitleOffset)
make.height.equalTo(TopSiteCellUX.TitleHeight)
make.bottom.equalTo(self)
}
imageView.snp.makeConstraints { make in
make.size.equalTo(floor(frame.width * TopSiteCellUX.IconSizePercent))
make.centerX.equalTo(self)
make.centerY.equalTo(self).inset(-TopSiteCellUX.TitleHeight/2)
}
selectedOverlay.snp.makeConstraints { make in
make.edges.equalTo(contentView)
}
faviconBG.snp.makeConstraints { make in
make.top.left.right.equalTo(self)
make.bottom.equalTo(self).inset(TopSiteCellUX.TitleHeight)
}
}
override func layoutSubviews() {
super.layoutSubviews()
titleBorder.frame = CGRect(x: 0, y: frame.height - TopSiteCellUX.TitleHeight - TopSiteCellUX.BorderWidth, width: frame.width, height: TopSiteCellUX.BorderWidth)
imageView.snp.remakeConstraints { make in
make.size.equalTo(floor(self.frame.width * TopSiteCellUX.IconSizePercent))
make.centerX.equalTo(self)
make.centerY.equalTo(self).inset(-TopSiteCellUX.TitleHeight/2)
}
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func prepareForReuse() {
super.prepareForReuse()
contentView.backgroundColor = UIColor.clear
imageView.image = nil
imageView.backgroundColor = UIColor.clear
faviconBG.backgroundColor = UIColor.clear
pinImageView.removeFromSuperview()
imageView.sd_cancelCurrentImageLoad()
titleLabel.text = ""
titleLabel.snp.updateConstraints { make in
make.left.equalTo(self).offset(TopSiteCellUX.TitleOffset)
}
}
func configureWithTopSiteItem(_ site: Site) {
url = site.tileURL
if let provider = site.metadata?.providerName {
titleLabel.text = provider.lowercased()
} else {
titleLabel.text = site.tileURL.hostSLD
}
// If its a pinned site add a bullet point to the front
if let _ = site as? PinnedSite {
contentView.addSubview(pinImageView)
pinImageView.snp.makeConstraints { make in
make.right.equalTo(self.titleLabel.snp.left)
make.size.equalTo(TopSiteCellUX.PinIconSize)
make.centerY.equalTo(self.titleLabel.snp.centerY)
}
titleLabel.snp.updateConstraints { make in
make.left.equalTo(self).offset(TopSiteCellUX.PinIconSize)
}
}
accessibilityLabel = titleLabel.text
imageView.setFavicon(forSite: site, onCompletion: { [weak self] (color, url) in
if let url = url, url == self?.url {
self?.faviconBG.backgroundColor = color
self?.imageView.backgroundColor = color
}
})
}
}
// An empty cell to show when a row is incomplete
class EmptyTopsiteDecorationCell: UICollectionReusableView {
override init(frame: CGRect) {
super.init(frame: frame)
self.layer.cornerRadius = TopSiteCellUX.CellCornerRadius
self.layer.borderWidth = TopSiteCellUX.BorderWidth
self.layer.borderColor = TopSiteCellUX.BorderColor.cgColor
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
struct ASHorizontalScrollCellUX {
static let TopSiteCellIdentifier = "TopSiteItemCell"
static let TopSiteEmptyCellIdentifier = "TopSiteItemEmptyCell"
static let TopSiteItemSize = CGSize(width: 75, height: 75)
static let BackgroundColor = UIColor.white
static let PageControlRadius: CGFloat = 3
static let PageControlSize = CGSize(width: 30, height: 15)
static let PageControlOffset: CGFloat = 12
static let MinimumInsets: CGFloat = 14
}
/*
The View that describes the topSite cell that appears in the tableView.
*/
class ASHorizontalScrollCell: UICollectionViewCell {
lazy var collectionView: UICollectionView = {
let layout = HorizontalFlowLayout()
layout.itemSize = ASHorizontalScrollCellUX.TopSiteItemSize
let collectionView = UICollectionView(frame: CGRect.zero, collectionViewLayout: layout)
collectionView.register(TopSiteItemCell.self, forCellWithReuseIdentifier: ASHorizontalScrollCellUX.TopSiteCellIdentifier)
collectionView.backgroundColor = UIColor.clear
collectionView.showsHorizontalScrollIndicator = false
collectionView.isPagingEnabled = true
return collectionView
}()
lazy fileprivate var pageControl: FilledPageControl = {
let pageControl = FilledPageControl()
pageControl.tintColor = UIColor.gray
pageControl.indicatorRadius = ASHorizontalScrollCellUX.PageControlRadius
pageControl.isUserInteractionEnabled = true
pageControl.isAccessibilityElement = true
pageControl.accessibilityIdentifier = "pageControl"
pageControl.accessibilityLabel = Strings.ASPageControlButton
pageControl.accessibilityTraits = UIAccessibilityTraitButton
return pageControl
}()
lazy fileprivate var pageControlPress: UITapGestureRecognizer = {
let press = UITapGestureRecognizer(target: self, action: #selector(ASHorizontalScrollCell.handlePageTap(_:)))
// press.delegate = self
return press
}()
weak var delegate: ASHorizontalScrollCellManager? {
didSet {
collectionView.delegate = delegate
collectionView.dataSource = delegate
delegate?.pageChangedHandler = { [weak self] progress in
self?.currentPageChanged(progress)
}
}
}
override init(frame: CGRect) {
super.init(frame: frame)
isAccessibilityElement = false
accessibilityIdentifier = "TopSitesCell"
backgroundColor = UIColor.clear
contentView.addSubview(collectionView)
contentView.addSubview(pageControl)
pageControl.addGestureRecognizer(self.pageControlPress)
collectionView.snp.makeConstraints { make in
make.edges.equalTo(contentView)
}
pageControl.snp.makeConstraints { make in
make.size.equalTo(ASHorizontalScrollCellUX.PageControlSize)
make.top.equalTo(collectionView.snp.bottom).inset(ASHorizontalScrollCellUX.PageControlOffset)
make.centerX.equalTo(self.snp.centerX)
}
}
override func layoutSubviews() {
super.layoutSubviews()
let layout = collectionView.collectionViewLayout as! HorizontalFlowLayout
pageControl.pageCount = layout.numberOfPages(with: self.frame.size)
pageControl.isHidden = pageControl.pageCount <= 1
}
func currentPageChanged(_ currentPage: CGFloat) {
pageControl.progress = currentPage
if currentPage == floor(currentPage) {
UIAccessibilityPostNotification(UIAccessibilityLayoutChangedNotification, nil)
self.setNeedsLayout()
}
}
func handlePageTap(_ gesture: UITapGestureRecognizer) {
guard pageControl.pageCount > 1 else {
return
}
if pageControl.pageCount > pageControl.currentPage + 1 {
pageControl.progress = CGFloat(pageControl.currentPage + 1)
} else {
pageControl.progress = CGFloat(pageControl.currentPage - 1)
}
let swipeCoordinate = CGFloat(pageControl.currentPage) * self.collectionView.frame.size.width
self.collectionView.setContentOffset(CGPoint(x: swipeCoordinate, y: 0), animated: true)
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
/*
A custom layout used to show a horizontal scrolling list with paging. Similar to iOS springboard.
A modified version of http://stackoverflow.com/a/34167915
*/
class HorizontalFlowLayout: UICollectionViewLayout {
fileprivate var cellCount: Int {
if let collectionView = collectionView, let dataSource = collectionView.dataSource {
return dataSource.collectionView(collectionView, numberOfItemsInSection: 0)
}
return 0
}
var boundsSize = CGSize.zero
private var insets = UIEdgeInsets(equalInset: ASHorizontalScrollCellUX.MinimumInsets)
private var sectionInsets: CGFloat = 0
var itemSize = CGSize.zero
var cachedAttributes: [UICollectionViewLayoutAttributes]?
override func prepare() {
super.prepare()
if boundsSize != self.collectionView?.frame.size {
self.collectionView?.setContentOffset(CGPoint.zero, animated: false)
}
boundsSize = self.collectionView?.frame.size ?? CGSize.zero
cachedAttributes = nil
register(EmptyTopsiteDecorationCell.self, forDecorationViewOfKind: ASHorizontalScrollCellUX.TopSiteEmptyCellIdentifier)
}
func numberOfPages(with bounds: CGSize) -> Int {
let itemsPerPage = maxVerticalItemsCount(height: bounds.height) * maxHorizontalItemsCount(width: bounds.width)
// Sometimes itemsPerPage is 0. In this case just return 0. We dont want to try dividing by 0.
return itemsPerPage == 0 ? 0 : Int(ceil(Double(cellCount) / Double(itemsPerPage)))
}
func calculateLayout(for size: CGSize) -> (size: CGSize, cellSize: CGSize, cellInsets: UIEdgeInsets) {
let width = size.width
let height = size.height
guard width != 0 else {
return (size: CGSize.zero, cellSize: self.itemSize, cellInsets: self.insets)
}
let horizontalItemsCount = maxHorizontalItemsCount(width: width)
var verticalItemsCount = maxVerticalItemsCount(height: height)
if cellCount <= horizontalItemsCount {
// If we have only a few items don't provide space for multiple rows.
verticalItemsCount = 1
}
// Take the number of cells and subtract its space in the view from the height. The left over space is the white space.
// The left over space is then devided evenly into (n + 1) parts to figure out how much space should be inbetween a cell
var verticalInsets = floor((height - (CGFloat(verticalItemsCount) * itemSize.height)) / CGFloat(verticalItemsCount + 1))
var horizontalInsets = floor((width - (CGFloat(horizontalItemsCount) * itemSize.width)) / CGFloat(horizontalItemsCount + 1))
// We want a minimum inset to make things not look crowded. We also don't want uneven spacing.
// If we dont have this. Set a minimum inset and recalculate the size of a cell
var estimatedItemSize = itemSize
if horizontalInsets != ASHorizontalScrollCellUX.MinimumInsets {
verticalInsets = ASHorizontalScrollCellUX.MinimumInsets
horizontalInsets = ASHorizontalScrollCellUX.MinimumInsets
estimatedItemSize.width = floor((width - (CGFloat(horizontalItemsCount + 1) * horizontalInsets)) / CGFloat(horizontalItemsCount))
estimatedItemSize.height = estimatedItemSize.width + TopSiteCellUX.TitleHeight
}
//calculate our estimates.
let estimatedHeight = floor(estimatedItemSize.height * CGFloat(verticalItemsCount)) + (verticalInsets * (CGFloat(verticalItemsCount) + 1))
let estimatedSize = CGSize(width: CGFloat(numberOfPages(with: boundsSize)) * width, height: estimatedHeight)
let estimatedInsets = UIEdgeInsets(top: verticalInsets, left: horizontalInsets, bottom: verticalInsets, right: horizontalInsets)
return (size: estimatedSize, cellSize: estimatedItemSize, cellInsets: estimatedInsets)
}
override var collectionViewContentSize: CGSize {
let estimatedLayout = calculateLayout(for: boundsSize)
insets = estimatedLayout.cellInsets
itemSize = estimatedLayout.cellSize
boundsSize.height = estimatedLayout.size.height
return estimatedLayout.size
}
func maxVerticalItemsCount(height: CGFloat) -> Int {
let verticalItemsCount = Int(floor(height / (ASHorizontalScrollCellUX.TopSiteItemSize.height + insets.top)))
if let delegate = self.collectionView?.delegate as? ASHorizontalLayoutDelegate {
return delegate.numberOfVerticalItems()
} else {
return verticalItemsCount
}
}
func maxHorizontalItemsCount(width: CGFloat) -> Int {
let horizontalItemsCount = Int(floor(width / (ASHorizontalScrollCellUX.TopSiteItemSize.width + insets.left)))
if let delegate = self.collectionView?.delegate as? ASHorizontalLayoutDelegate {
return delegate.numberOfHorizontalItems()
} else {
return horizontalItemsCount
}
}
override func layoutAttributesForDecorationView(ofKind elementKind: String, at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
let decorationAttr = UICollectionViewLayoutAttributes(forDecorationViewOfKind: elementKind, with: indexPath)
let cellAttr = self.computeLayoutAttributesForCellAtIndexPath(indexPath)
decorationAttr.frame = cellAttr.frame
decorationAttr.frame.size.height -= TopSiteCellUX.TitleHeight
decorationAttr.zIndex = -1
return decorationAttr
}
override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
if cachedAttributes != nil {
return cachedAttributes
}
var allAttributes = [UICollectionViewLayoutAttributes]()
for i in 0 ..< cellCount {
let indexPath = IndexPath(row: i, section: 0)
let attr = self.computeLayoutAttributesForCellAtIndexPath(indexPath)
allAttributes.append(attr)
}
//create decoration attributes
let horizontalItemsCount = maxHorizontalItemsCount(width: boundsSize.width)
var numberOfCells = cellCount
while numberOfCells % horizontalItemsCount != 0 {
//we need some empty cells dawg.
let attr = self.layoutAttributesForDecorationView(ofKind: ASHorizontalScrollCellUX.TopSiteEmptyCellIdentifier, at: IndexPath(item: numberOfCells, section: 0))
allAttributes.append(attr!)
numberOfCells += 1
}
cachedAttributes = allAttributes
return allAttributes
}
override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
return self.computeLayoutAttributesForCellAtIndexPath(indexPath)
}
override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool {
cachedAttributes = nil
// Sometimes when the topsiteCell isnt on the screen the newbounds that it tries to layout in is 0
// Resulting in incorrect layouts. Only layout when a valid width is given
return newBounds.width > 0
}
func computeLayoutAttributesForCellAtIndexPath(_ indexPath: IndexPath) -> UICollectionViewLayoutAttributes {
let row = indexPath.row
let bounds = self.collectionView!.bounds
let verticalItemsCount = maxVerticalItemsCount(height: bounds.size.height)
let horizontalItemsCount = maxHorizontalItemsCount(width: bounds.size.width)
let itemsPerPage = verticalItemsCount * horizontalItemsCount
let columnPosition = row % horizontalItemsCount
let rowPosition = (row / horizontalItemsCount) % verticalItemsCount
let itemPage = Int(floor(Double(row)/Double(itemsPerPage)))
let attr = UICollectionViewLayoutAttributes(forCellWith: indexPath)
var frame = CGRect.zero
frame.origin.x = CGFloat(itemPage) * bounds.size.width + CGFloat(columnPosition) * (itemSize.width + insets.left) + insets.left
frame.origin.y = CGFloat(rowPosition) * (itemSize.height + insets.top) + insets.top
frame.size = itemSize
attr.frame = frame
return attr
}
}
/*
Defines the number of items to show in topsites for different size classes.
*/
struct ASTopSiteSourceUX {
static let verticalItemsForTraitSizes = [UIUserInterfaceSizeClass.compact: 1, UIUserInterfaceSizeClass.regular: 2, UIUserInterfaceSizeClass.unspecified: 0]
static let maxNumberOfPages = 2
static let CellIdentifier = "TopSiteItemCell"
}
protocol ASHorizontalLayoutDelegate {
func numberOfVerticalItems() -> Int
func numberOfHorizontalItems() -> Int
}
/*
This Delegate/DataSource is used to manage the ASHorizontalScrollCell's UICollectionView.
This is left generic enough for it to be re used for other parts of Activity Stream.
*/
class ASHorizontalScrollCellManager: NSObject, UICollectionViewDelegate, UICollectionViewDataSource, ASHorizontalLayoutDelegate {
var content: [Site] = []
var urlPressedHandler: ((URL, IndexPath) -> Void)?
var pageChangedHandler: ((CGFloat) -> Void)?
// The current traits that define the parent ViewController. Used to determine how many rows/columns should be created.
var currentTraits: UITraitCollection?
// Size classes define how many items to show per row/column.
func numberOfVerticalItems() -> Int {
guard let traits = currentTraits else {
return 0
}
return ASTopSiteSourceUX.verticalItemsForTraitSizes[traits.verticalSizeClass]!
}
func numberOfHorizontalItems() -> Int {
guard let traits = currentTraits else {
return 0
}
let isLandscape = UIInterfaceOrientationIsLandscape(UIApplication.shared.statusBarOrientation)
if UIDevice.current.userInterfaceIdiom == .phone {
if isLandscape {
return 8
} else {
return 4
}
}
// On iPad
// The number of items in a row is equal to the number of highlights in a row * 2
var numItems: Int = Int(ASPanelUX.numberOfItemsPerRowForSizeClassIpad[traits.horizontalSizeClass])
if UIInterfaceOrientationIsPortrait(UIApplication.shared.statusBarOrientation) || (traits.horizontalSizeClass == .compact && isLandscape) {
numItems = numItems - 1
}
return numItems * 2
}
func numberOfSections(in collectionView: UICollectionView) -> Int {
return 1
}
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return self.content.count
}
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: ASTopSiteSourceUX.CellIdentifier, for: indexPath) as! TopSiteItemCell
let contentItem = content[indexPath.row]
cell.configureWithTopSiteItem(contentItem)
return cell
}
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
let contentItem = content[indexPath.row]
guard let url = contentItem.url.asURL else {
return
}
urlPressedHandler?(url, indexPath)
}
func scrollViewDidScroll(_ scrollView: UIScrollView) {
let pageWidth = scrollView.frame.width
pageChangedHandler?(scrollView.contentOffset.x / pageWidth)
}
}

View file

@ -0,0 +1,593 @@
/* 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()
}
}

View file

@ -0,0 +1,574 @@
/* 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 Shared
import Storage
import XCGLogger
import Deferred
private typealias SectionNumber = Int
private typealias CategoryNumber = Int
private typealias CategorySpec = (section: SectionNumber?, rows: Int, offset: Int)
private struct HistoryPanelUX {
static let WelcomeScreenItemTextColor = UIColor.gray
static let WelcomeScreenItemWidth = 170
static let IconSize = 23
static let IconBorderColor = UIColor(white: 0, alpha: 0.1)
static let IconBorderWidth: CGFloat = 0.5
}
private func getDate(_ dayOffset: Int) -> Date {
let calendar = Calendar(identifier: Calendar.Identifier.gregorian)
let nowComponents = (calendar as NSCalendar).components([.year, .month, .day], from: Date())
let today = calendar.date(from: nowComponents)!
return (calendar as NSCalendar).date(byAdding: NSCalendar.Unit.day, value: dayOffset, to: today, options: [])!
}
class HistoryPanel: SiteTableViewController, HomePanel {
weak var homePanelDelegate: HomePanelDelegate?
private var currentSyncedDevicesCount: Int?
var events = [NotificationFirefoxAccountChanged, NotificationPrivateDataClearedHistory, NotificationDynamicFontChanged]
var refreshControl: UIRefreshControl?
fileprivate lazy var longPressRecognizer: UILongPressGestureRecognizer = {
return UILongPressGestureRecognizer(target: self, action: #selector(HistoryPanel.longPress(_:)))
}()
private lazy var emptyStateOverlayView: UIView = self.createEmptyStateOverlayView()
private let QueryLimit = 100
private let NumSections = 5
private let Today = getDate(0)
private let Yesterday = getDate(-1)
private let ThisWeek = getDate(-7)
private var categories: [CategorySpec] = [CategorySpec]() // Category number (index) -> (UI section, row count, cursor offset).
private var sectionLookup = [SectionNumber: CategoryNumber]() // Reverse lookup from UI section to data category.
var syncDetailText = ""
var hasRecentlyClosed: Bool {
return self.profile.recentlyClosedTabs.tabs.count > 0
}
// MARK: - Lifecycle
init() {
super.init(nibName: nil, bundle: nil)
events.forEach { NotificationCenter.default.addObserver(self, selector: #selector(HistoryPanel.notificationReceived(_:)), name: $0, object: nil) }
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
tableView.addGestureRecognizer(longPressRecognizer)
tableView.accessibilityIdentifier = "History List"
updateSyncedDevicesCount().uponQueue(DispatchQueue.main) { result in
self.updateNumberOfSyncedDevices(self.currentSyncedDevicesCount)
}
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
// Add a refresh control if the user is logged in and the control was not added before. If the user is not
// logged in, remove any existing control but only when it is not currently refreshing. Otherwise, wait for
// the refresh to finish before removing the control.
if profile.hasSyncableAccount() && refreshControl == nil {
addRefreshControl()
} else if refreshControl?.isRefreshing == false {
removeRefreshControl()
}
if profile.hasSyncableAccount() {
syncDetailText = " "
updateSyncedDevicesCount().uponQueue(DispatchQueue.main) { result in
self.updateNumberOfSyncedDevices(self.currentSyncedDevicesCount)
}
} else {
syncDetailText = ""
}
}
@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 }
if indexPath.section != 0 {
presentContextMenu(for: indexPath)
}
}
// MARK: - History Data Store
func updateNumberOfSyncedDevices(_ count: Int?) {
if let count = count, count > 0 {
syncDetailText = String.localizedStringWithFormat(Strings.SyncedTabsTableViewCellDescription, count)
} else {
syncDetailText = ""
}
self.tableView.reloadRows(at: [IndexPath(row: 1, section: 0)], with: .automatic)
}
func updateSyncedDevicesCount() -> Success {
return chainDeferred(self.profile.getCachedClientsAndTabs()) { tabsAndClients in
self.currentSyncedDevicesCount = tabsAndClients.count
return succeed()
}
}
func notificationReceived(_ notification: Notification) {
reloadData()
switch notification.name {
case NotificationFirefoxAccountChanged, NotificationPrivateDataClearedHistory:
if self.profile.hasSyncableAccount() {
resyncHistory()
}
break
case NotificationDynamicFontChanged:
if emptyStateOverlayView.superview != nil {
emptyStateOverlayView.removeFromSuperview()
}
emptyStateOverlayView = createEmptyStateOverlayView()
resyncHistory()
break
default:
// no need to do anything at all
print("Error: Received unexpected notification \(notification.name)")
break
}
}
private func fetchData() -> Deferred<Maybe<Cursor<Site>>> {
return profile.history.getSitesByLastVisit(QueryLimit)
}
private func setData(_ data: Cursor<Site>) {
self.data = data
self.computeSectionOffsets()
}
func resyncHistory() {
profile.syncManager.syncHistory().uponQueue(DispatchQueue.main) { result in
if result.isSuccess {
self.reloadData()
} else {
self.endRefreshing()
}
self.updateSyncedDevicesCount().uponQueue(DispatchQueue.main) { result in
self.updateNumberOfSyncedDevices(self.currentSyncedDevicesCount)
}
}
}
// MARK: - Refreshing TableView
func addRefreshControl() {
let refresh = UIRefreshControl()
refresh.addTarget(self, action: #selector(HistoryPanel.refresh), for: UIControlEvents.valueChanged)
self.refreshControl = refresh
self.tableView.refreshControl = refresh
}
func removeRefreshControl() {
self.tableView.refreshControl = nil
self.refreshControl = nil
}
func endRefreshing() {
// Always end refreshing, even if we failed!
self.refreshControl?.endRefreshing()
// Remove the refresh control if the user has logged out in the meantime
if !self.profile.hasSyncableAccount() {
self.removeRefreshControl()
}
}
@objc func refresh() {
self.refreshControl?.beginRefreshing()
resyncHistory()
}
override func reloadData() {
self.fetchData().uponQueue(DispatchQueue.main) { result in
if let data = result.successValue {
self.setData(data)
self.tableView.reloadData()
self.updateEmptyPanelState()
}
self.endRefreshing()
}
}
// MARK: - Empty State
private func updateEmptyPanelState() {
if data.count == 0 {
if self.emptyStateOverlayView.superview == nil {
self.tableView.addSubview(self.emptyStateOverlayView)
self.emptyStateOverlayView.snp.makeConstraints { make -> Void in
make.left.right.bottom.equalTo(self.view)
make.top.equalTo(self.view).offset(100)
}
}
} else {
self.tableView.alwaysBounceVertical = true
self.emptyStateOverlayView.removeFromSuperview()
}
}
private func createEmptyStateOverlayView() -> UIView {
let overlayView = UIView()
overlayView.backgroundColor = UIColor.white
let welcomeLabel = UILabel()
overlayView.addSubview(welcomeLabel)
welcomeLabel.text = Strings.HistoryPanelEmptyStateTitle
welcomeLabel.textAlignment = NSTextAlignment.center
welcomeLabel.font = DynamicFontHelper.defaultHelper.DeviceFontLight
welcomeLabel.textColor = HistoryPanelUX.WelcomeScreenItemTextColor
welcomeLabel.numberOfLines = 0
welcomeLabel.adjustsFontSizeToFitWidth = true
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(HomePanelUX.EmptyTabContentOffset).priority(100)
// Sets proper top constraint for iPhone 4, 5 in portrait.
make.top.greaterThanOrEqualTo(overlayView).offset(50)
make.width.equalTo(HistoryPanelUX.WelcomeScreenItemWidth)
}
return overlayView
}
// MARK: - TableView Row Helpers
func computeSectionOffsets() {
var counts = [Int](repeating: 0, count: NumSections)
// Loop over all the data. Record the start of each "section" of our list.
for i in 0..<data.count {
if let site = data[i] {
counts[categoryForDate(site.latestVisit!.date) + 1] += 1
}
}
var section = 0
var offset = 0
self.categories = [CategorySpec]()
for i in 0..<NumSections {
let count = counts[i]
if i == 0 {
sectionLookup[section] = i
section += 1
}
if count > 0 {
self.categories.append((section: section, rows: count, offset: offset))
sectionLookup[section] = i
offset += count
section += 1
} else {
self.categories.append((section: nil, rows: 0, offset: offset))
}
}
}
fileprivate func siteForIndexPath(_ indexPath: IndexPath) -> Site? {
let offset = self.categories[sectionLookup[indexPath.section]!].offset
return data[indexPath.row + offset]
}
private func categoryForDate(_ date: MicrosecondTimestamp) -> Int {
let date = Double(date)
if date > (1000000 * Today.timeIntervalSince1970) {
return 0
}
if date > (1000000 * Yesterday.timeIntervalSince1970) {
return 1
}
if date > (1000000 * ThisWeek.timeIntervalSince1970) {
return 2
}
return 3
}
private func isInCategory(_ date: MicrosecondTimestamp, category: Int) -> Bool {
return self.categoryForDate(date) == category
}
// UI sections disappear as categories empty. We need to translate back and forth.
private func uiSectionToCategory(_ section: SectionNumber) -> CategoryNumber {
for i in 0..<self.categories.count {
if let s = self.categories[i].section, s == section {
return i
}
}
return 0
}
private func categoryToUISection(_ category: CategoryNumber) -> SectionNumber? {
return self.categories[category].section
}
// MARK: - TableView Delegate / DataSource
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = super.tableView(tableView, cellForRowAt: indexPath)
cell.accessoryType = UITableViewCellAccessoryType.none
if indexPath.section == 0 {
cell.imageView!.layer.borderWidth = 0
return indexPath.row == 0 ? configureRecentlyClosed(cell, for: indexPath) : configureSyncedTabs(cell, for: indexPath)
} else {
return configureSite(cell, for: indexPath)
}
}
func configureRecentlyClosed(_ cell: UITableViewCell, for indexPath: IndexPath) -> UITableViewCell {
cell.accessoryType = UITableViewCellAccessoryType.disclosureIndicator
cell.textLabel!.text = Strings.RecentlyClosedTabsButtonTitle
cell.detailTextLabel!.text = ""
cell.imageView!.image = UIImage(named: "recently_closed")
cell.imageView?.backgroundColor = UIColor.white
if !hasRecentlyClosed {
cell.textLabel?.alpha = 0.5
cell.imageView!.alpha = 0.5
cell.selectionStyle = .none
}
cell.accessibilityIdentifier = "HistoryPanel.recentlyClosedCell"
return cell
}
func configureSyncedTabs(_ cell: UITableViewCell, for indexPath: IndexPath) -> UITableViewCell {
cell.accessoryType = UITableViewCellAccessoryType.disclosureIndicator
cell.textLabel!.text = Strings.SyncedTabsTableViewCellTitle
cell.detailTextLabel!.text = self.syncDetailText
cell.imageView!.image = UIImage(named: "synced_devices")
cell.imageView?.backgroundColor = UIColor.white
cell.accessibilityIdentifier = "HistoryPanel.syncedDevicesCell"
return cell
}
func configureSite(_ cell: UITableViewCell, for indexPath: IndexPath) -> UITableViewCell {
if let site = siteForIndexPath(indexPath), let cell = cell as? TwoLineTableViewCell {
cell.setLines(site.title, detailText: site.url)
cell.imageView!.layer.borderColor = HistoryPanelUX.IconBorderColor.cgColor
cell.imageView!.layer.borderWidth = HistoryPanelUX.IconBorderWidth
cell.imageView?.setIcon(site.icon, forURL: site.tileURL, completed: { (color, url) in
if site.tileURL == url {
cell.imageView?.image = cell.imageView?.image?.createScaled(CGSize(width: HistoryPanelUX.IconSize, height: HistoryPanelUX.IconSize))
cell.imageView?.backgroundColor = color
cell.imageView?.contentMode = .center
}
})
}
return cell
}
func numberOfSectionsInTableView(_ tableView: UITableView) -> Int {
var count = 1
for category in self.categories where category.rows > 0 {
count += 1
}
return count
}
func tableView(_ tableView: UITableView, didSelectRowAtIndexPath indexPath: IndexPath) {
if indexPath.section == 0 {
self.tableView.deselectRow(at: indexPath, animated: true)
return indexPath.row == 0 ? self.showRecentlyClosed() : self.showSyncedTabs()
}
if let site = self.siteForIndexPath(indexPath), let url = URL(string: site.url) {
let visitType = VisitType.typed // Means History, too.
if let homePanelDelegate = homePanelDelegate {
homePanelDelegate.homePanel(self, didSelectURL: url, visitType: visitType)
}
return
}
print("Error: No site or no URL when selecting row.")
}
func pinTopSite(_ site: Site) {
_ = profile.history.addPinnedTopSite(site).value
}
func showSyncedTabs() {
let nextController = RemoteTabsPanel()
nextController.homePanelDelegate = self.homePanelDelegate
nextController.profile = self.profile
self.refreshControl?.endRefreshing()
self.navigationController?.pushViewController(nextController, animated: true)
}
func showRecentlyClosed() {
guard hasRecentlyClosed else {
return
}
let nextController = RecentlyClosedTabsPanel()
nextController.homePanelDelegate = self.homePanelDelegate
nextController.profile = self.profile
self.refreshControl?.endRefreshing()
self.navigationController?.pushViewController(nextController, animated: true)
}
func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
var title = String()
switch sectionLookup[section]! {
case 0: return nil
case 1: title = NSLocalizedString("Today", comment: "History tableview section header")
case 2: title = NSLocalizedString("Yesterday", comment: "History tableview section header")
case 3: title = NSLocalizedString("Last week", comment: "History tableview section header")
case 4: title = NSLocalizedString("Last month", comment: "History tableview section header")
default:
assertionFailure("Invalid history section \(section)")
}
return title
}
override func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? {
if section == 0 {
return nil
}
return super.tableView(tableView, viewForHeaderInSection: section)
}
override func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat {
if section == 0 {
return 0
}
return super.tableView(tableView, heightForHeaderInSection: section)
}
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
if section == 0 {
return 2
}
return self.categories[uiSectionToCategory(section)].rows
}
func tableView(_ tableView: UITableView, commitEditingStyle editingStyle: UITableViewCellEditingStyle, forRowAtIndexPath indexPath: IndexPath) {
// Intentionally blank. Required to use UITableViewRowActions
}
fileprivate func removeHistoryForURLAtIndexPath(indexPath: IndexPath) {
if let site = self.siteForIndexPath(indexPath) {
// Why the dispatches? Because we call success and failure on the DB
// queue, and so calling anything else that calls through to the DB will
// deadlock. This problem will go away when the history API switches to
// Deferred instead of using callbacks.
self.profile.history.removeHistoryForURL(site.url)
.upon { res in
self.fetchData().uponQueue(DispatchQueue.main) { result in
// If a section will be empty after removal, we must remove the section itself.
if let data = result.successValue {
let oldCategories = self.categories
self.data = data
self.computeSectionOffsets()
let sectionsToDelete = NSMutableIndexSet()
var rowsToDelete = [IndexPath]()
let sectionsToAdd = NSMutableIndexSet()
var rowsToAdd = [IndexPath]()
for (index, category) in self.categories.enumerated() {
let oldCategory = oldCategories[index]
// don't bother if we're not displaying this category
if oldCategory.section == nil && category.section == nil {
continue
}
// 1. add a new section if the section didn't previously exist
if oldCategory.section == nil && category.section != oldCategory.section {
sectionsToAdd.add(category.section!)
}
// 2. add a new row if there are more rows now than there were before
if oldCategory.rows < category.rows {
rowsToAdd.append(IndexPath(row: category.rows-1, section: category.section!))
}
// if we're dealing with the section where the row was deleted:
// 1. if the category no longer has a section, then we need to delete the entire section
// 2. delete a row if the number of rows has been reduced
// 3. delete the selected row and add a new one on the bottom of the section if the number of rows has stayed the same
if oldCategory.section == indexPath.section {
if category.section == nil {
sectionsToDelete.add(indexPath.section)
} else if oldCategory.section == category.section {
if oldCategory.rows > category.rows {
rowsToDelete.append(indexPath)
} else if category.rows == oldCategory.rows {
rowsToDelete.append(indexPath)
rowsToAdd.append(IndexPath(row: category.rows-1, section: indexPath.section))
}
}
}
}
self.tableView.beginUpdates()
if sectionsToAdd.count > 0 {
self.tableView.insertSections(sectionsToAdd as IndexSet, with: UITableViewRowAnimation.left)
}
if sectionsToDelete.count > 0 {
self.tableView.deleteSections(sectionsToDelete as IndexSet, with: UITableViewRowAnimation.right)
}
if !rowsToDelete.isEmpty {
self.tableView.deleteRows(at: rowsToDelete, with: UITableViewRowAnimation.right)
}
if !rowsToAdd.isEmpty {
self.tableView.insertRows(at: rowsToAdd, with: UITableViewRowAnimation.right)
}
self.tableView.endUpdates()
self.updateEmptyPanelState()
}
}
}
}
}
func tableView(_ tableView: UITableView, editActionsForRowAtIndexPath indexPath: IndexPath) -> [AnyObject]? {
if indexPath.section == 0 {
return []
}
let title = NSLocalizedString("Delete", tableName: "HistoryPanel", comment: "Action button for deleting history entries in the history panel.")
let delete = UITableViewRowAction(style: UITableViewRowActionStyle.default, title: title, handler: { (action, indexPath) in
self.removeHistoryForURLAtIndexPath(indexPath: indexPath)
})
return [delete]
}
}
extension HistoryPanel: 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? {
return siteForIndexPath(indexPath)
}
func getContextMenuActions(for site: Site, with indexPath: IndexPath) -> [PhotonActionSheetItem]? {
guard var actions = getDefaultContextMenuActions(for: site, homePanelDelegate: homePanelDelegate) else { return nil }
let removeAction = PhotonActionSheetItem(title: Strings.DeleteFromHistoryContextMenuTitle, iconString: "action_delete", handler: { action in
self.removeHistoryForURLAtIndexPath(indexPath: indexPath)
})
let pinTopSite = PhotonActionSheetItem(title: Strings.PinTopsiteActionTitle, iconString: "action_pin", handler: { action in
self.pinTopSite(site)
})
actions.append(pinTopSite)
actions.append(removeAction)
return actions
}
}

View file

@ -0,0 +1,23 @@
{
"images" : [
{
"idiom" : "universal",
"filename" : "reading list add circle.png",
"scale" : "1x"
},
{
"idiom" : "universal",
"filename" : "reading list add circle@2x.png",
"scale" : "2x"
},
{
"idiom" : "universal",
"filename" : "reading list add circle@3x.png",
"scale" : "3x"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

View file

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

View file

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

View file

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 609 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 910 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

View file

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

View file

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

View file

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View file

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 924 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

View file

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 334 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 486 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 664 B

View file

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 266 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 438 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 602 B

View file

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 188 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 267 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 341 B

View file

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 798 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

View file

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 191 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 243 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 324 B

View file

@ -0,0 +1,364 @@
/* 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 Foundation
import Shared
import SnapKit
import UIKit
import Storage
private struct HomePanelViewControllerUX {
// Height of the top panel switcher button toolbar.
static let ButtonContainerHeight: CGFloat = 40
static let ButtonContainerBorderColor = UIColor.black.withAlphaComponent(0.1)
static let BackgroundColorPrivateMode = UIConstants.PrivateModeAssistantToolbarBackgroundColor
static let ToolbarButtonDeselectedColorNormalMode = UIColor(white: 0.2, alpha: 0.5)
static let ToolbarButtonDeselectedColorPrivateMode = UIColor(white: 0.9, alpha: 1)
static let ButtonHighlightLineHeight: CGFloat = 2
static let ButtonSelectionAnimationDuration = 0.2
}
protocol HomePanelViewControllerDelegate: class {
func homePanelViewController(_ homePanelViewController: HomePanelViewController, didSelectURL url: URL, visitType: VisitType)
func homePanelViewController(_ HomePanelViewController: HomePanelViewController, didSelectPanel panel: Int)
func homePanelViewControllerDidRequestToSignIn(_ homePanelViewController: HomePanelViewController)
func homePanelViewControllerDidRequestToCreateAccount(_ homePanelViewController: HomePanelViewController)
func homePanelViewControllerDidRequestToOpenInNewTab(_ url: URL, isPrivate: Bool)
}
protocol HomePanel: class {
weak var homePanelDelegate: HomePanelDelegate? { get set }
}
struct HomePanelUX {
static let EmptyTabContentOffset = -180
}
protocol HomePanelDelegate: class {
func homePanelDidRequestToSignIn(_ homePanel: HomePanel)
func homePanelDidRequestToCreateAccount(_ homePanel: HomePanel)
func homePanelDidRequestToOpenInNewTab(_ url: URL, isPrivate: Bool)
func homePanel(_ homePanel: HomePanel, didSelectURL url: URL, visitType: VisitType)
func homePanel(_ homePanel: HomePanel, didSelectURLString url: String, visitType: VisitType)
}
struct HomePanelState {
var selectedIndex: Int = 0
}
enum HomePanelType: Int {
case topSites = 0
case bookmarks = 1
case history = 2
case readingList = 3
var localhostURL: URL {
return URL(string: "#panel=\(self.rawValue)", relativeTo: UIConstants.AboutHomePage as URL)!
}
}
class HomePanelViewController: UIViewController, UITextFieldDelegate, HomePanelDelegate {
static let Themes: [String: Theme] = {
var themes = [String: Theme]()
var theme = Theme()
theme.backgroundColor = UIConstants.AppBackgroundColor
theme.buttonTintColor = UIColor(rgb: 0x7e7e7f)
theme.highlightButtonColor = UIConstants.HighlightBlue
themes[Theme.PrivateMode] = theme
theme = Theme()
theme.backgroundColor = UIConstants.AppBackgroundColor
theme.buttonTintColor = UIColor(rgb: 0x7e7e7f)
theme.highlightButtonColor = UIConstants.HighlightBlue
themes[Theme.NormalMode] = theme
return themes
}()
var profile: Profile!
var notificationToken: NSObjectProtocol!
var panels: [HomePanelDescriptor]!
var url: URL?
weak var delegate: HomePanelViewControllerDelegate?
fileprivate var buttonContainerView = UIStackView()
fileprivate var buttonContainerBottomBorderView: UIView!
fileprivate var controllerContainerView: UIView!
fileprivate var buttons: [UIButton] = []
fileprivate var highlightLine = UIView() //The line underneath a panel button that shows which one is selected
fileprivate var buttonTintColor: UIColor?
fileprivate var buttonSelectedTintColor: UIColor?
var homePanelState: HomePanelState {
return HomePanelState(selectedIndex: selectedPanel?.rawValue ?? 0)
}
override func viewDidLoad() {
view.backgroundColor = UIConstants.AppBackgroundColor
buttonContainerView.axis = .horizontal
buttonContainerView.alignment = .fill
buttonContainerView.distribution = .fillEqually
buttonContainerView.spacing = 14
buttonContainerView.clipsToBounds = true
buttonContainerView.accessibilityNavigationStyle = .combined
buttonContainerView.accessibilityLabel = NSLocalizedString("Panel Chooser", comment: "Accessibility label for the Home panel's top toolbar containing list of the home panels (top sites, bookmarsk, history, remote tabs, reading list).")
view.addSubview(buttonContainerView)
buttonContainerView.addSubview(highlightLine)
self.buttonContainerBottomBorderView = UIView()
self.view.addSubview(buttonContainerBottomBorderView)
buttonContainerBottomBorderView.backgroundColor = HomePanelViewControllerUX.ButtonContainerBorderColor
controllerContainerView = UIView()
view.addSubview(controllerContainerView)
buttonContainerView.snp.makeConstraints { make in
make.top.equalTo(self.view)
make.leading.trailing.equalTo(self.view).inset(14)
make.height.equalTo(HomePanelViewControllerUX.ButtonContainerHeight)
}
buttonContainerBottomBorderView.snp.makeConstraints { make in
make.top.equalTo(self.buttonContainerView.snp.bottom).offset(-1)
make.bottom.equalTo(self.buttonContainerView)
make.leading.trailing.equalToSuperview()
}
controllerContainerView.snp.makeConstraints { make in
make.top.equalTo(self.buttonContainerView.snp.bottom)
make.left.right.bottom.equalTo(self.view)
}
self.panels = HomePanels().enabledPanels
updateButtons()
// Gesture recognizer to dismiss the keyboard in the URLBarView when the buttonContainerView is tapped
let dismissKeyboardGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(HomePanelViewController.dismissKeyboard(_:)))
dismissKeyboardGestureRecognizer.cancelsTouchesInView = false
buttonContainerView.addGestureRecognizer(dismissKeyboardGestureRecognizer)
}
func dismissKeyboard(_ gestureRecognizer: UITapGestureRecognizer) {
view.window?.rootViewController?.view.endEditing(true)
}
var selectedPanel: HomePanelType? = nil {
didSet {
if oldValue == selectedPanel {
// Prevent flicker, allocations, and disk access: avoid duplicate view controllers.
return
}
if let index = oldValue?.rawValue {
if index < buttons.count {
let currentButton = buttons[index]
currentButton.isSelected = false
currentButton.isUserInteractionEnabled = true
}
}
hideCurrentPanel()
if let index = selectedPanel?.rawValue {
if index < buttons.count {
let newButton = buttons[index]
newButton.isSelected = true
newButton.isUserInteractionEnabled = false
}
if index < panels.count {
let panel = self.panels[index].makeViewController(profile)
let accessibilityLabel = self.panels[index].accessibilityLabel
if let panelController = panel as? UINavigationController,
let rootPanel = panelController.viewControllers.first {
setupHomePanel(rootPanel, accessibilityLabel: accessibilityLabel)
self.showPanel(panelController)
} else {
setupHomePanel(panel, accessibilityLabel: accessibilityLabel)
self.showPanel(panel)
}
}
}
self.updateButtonTints()
}
}
func setupHomePanel(_ panel: UIViewController, accessibilityLabel: String) {
(panel as? HomePanel)?.homePanelDelegate = self
panel.view.accessibilityNavigationStyle = .combined
panel.view.accessibilityLabel = accessibilityLabel
}
override var preferredStatusBarStyle: UIStatusBarStyle {
return UIStatusBarStyle.lightContent
}
fileprivate func hideCurrentPanel() {
if let panel = childViewControllers.first {
panel.willMove(toParentViewController: nil)
panel.beginAppearanceTransition(false, animated: false)
panel.view.removeFromSuperview()
panel.endAppearanceTransition()
panel.removeFromParentViewController()
}
}
fileprivate func showPanel(_ panel: UIViewController) {
addChildViewController(panel)
panel.beginAppearanceTransition(true, animated: false)
controllerContainerView.addSubview(panel.view)
panel.endAppearanceTransition()
panel.view.snp.makeConstraints { make in
make.top.equalTo(self.buttonContainerView.snp.bottom)
make.left.right.bottom.equalTo(self.view)
}
panel.didMove(toParentViewController: self)
}
func tappedButton(_ sender: UIButton!) {
for (index, button) in buttons.enumerated() where button == sender {
selectedPanel = HomePanelType(rawValue: index)
delegate?.homePanelViewController(self, didSelectPanel: index)
if selectedPanel == .bookmarks {
UnifiedTelemetry.recordEvent(category: .action, method: .view, object: .bookmarksPanel, value: .homePanelTabButton)
}
break
}
}
fileprivate func updateButtons() {
for panel in panels {
let button = UIButton()
button.addTarget(self, action: #selector(HomePanelViewController.tappedButton(_:)), for: .touchUpInside)
if let image = UIImage.templateImageNamed("panelIcon\(panel.imageName)") {
button.setImage(image, for: UIControlState.normal)
}
button.imageEdgeInsets = UIEdgeInsets(top: 0, left: 0, bottom: 4, right: 0)
button.accessibilityLabel = panel.accessibilityLabel
button.accessibilityIdentifier = panel.accessibilityIdentifier
buttons.append(button)
self.buttonContainerView.addArrangedSubview(button)
}
}
func updateButtonTints() {
var selectedbutton: UIView?
for (index, button) in self.buttons.enumerated() {
if index == self.selectedPanel?.rawValue {
button.tintColor = self.buttonSelectedTintColor
selectedbutton = button
} else {
button.tintColor = self.buttonTintColor
}
}
guard let button = selectedbutton else {
return
}
// Calling this before makes sure that only the highlightline animates and not the homepanels
self.view.setNeedsUpdateConstraints()
self.view.layoutIfNeeded()
UIView.animate(withDuration: HomePanelViewControllerUX.ButtonSelectionAnimationDuration, delay: 0.0, usingSpringWithDamping: 0.85, initialSpringVelocity: 0.0, options: [], animations: { _ in
self.highlightLine.snp.remakeConstraints { make in
make.leading.equalTo(button.snp.leading)
make.trailing.equalTo(button.snp.trailing)
make.bottom.equalToSuperview()
make.height.equalTo(HomePanelViewControllerUX.ButtonHighlightLineHeight)
}
self.view.setNeedsUpdateConstraints()
self.view.layoutIfNeeded()
}, completion: nil)
}
func homePanel(_ homePanel: HomePanel, didSelectURLString url: String, visitType: VisitType) {
// If we can't get a real URL out of what should be a URL, we let the user's
// default search engine give it a shot.
// Typically we'll be in this state if the user has tapped a bookmarked search template
// (e.g., "http://foo.com/bar/?query=%s"), and this will get them the same behavior as if
// they'd copied and pasted into the URL bar.
// See BrowserViewController.urlBar:didSubmitText:.
guard let url = URIFixup.getURL(url) ?? profile.searchEngines.defaultEngine.searchURLForQuery(url) else {
Logger.browserLogger.warning("Invalid URL, and couldn't generate a search URL for it.")
return
}
return self.homePanel(homePanel, didSelectURL: url, visitType: visitType)
}
func homePanel(_ homePanel: HomePanel, didSelectURL url: URL, visitType: VisitType) {
delegate?.homePanelViewController(self, didSelectURL: url, visitType: visitType)
dismiss(animated: true, completion: nil)
}
func homePanelDidRequestToCreateAccount(_ homePanel: HomePanel) {
delegate?.homePanelViewControllerDidRequestToCreateAccount(self)
}
func homePanelDidRequestToSignIn(_ homePanel: HomePanel) {
delegate?.homePanelViewControllerDidRequestToSignIn(self)
}
func homePanelDidRequestToOpenInNewTab(_ url: URL, isPrivate: Bool) {
delegate?.homePanelViewControllerDidRequestToOpenInNewTab(url, isPrivate: isPrivate)
}
}
// MARK: UIAppearance
extension HomePanelViewController: Themeable {
func applyTheme(_ themeName: String) {
guard let theme = HomePanelViewController.Themes[themeName] else {
fatalError("Theme not found")
}
highlightLine.backgroundColor = theme.highlightButtonColor
buttonContainerView.backgroundColor = theme.backgroundColor
self.view.backgroundColor = theme.backgroundColor
buttonTintColor = theme.buttonTintColor
buttonSelectedTintColor = theme.highlightButtonColor
updateButtonTints()
}
}
protocol HomePanelContextMenu {
func getSiteDetails(for indexPath: IndexPath) -> Site?
func getContextMenuActions(for site: Site, with indexPath: IndexPath) -> [PhotonActionSheetItem]?
func presentContextMenu(for indexPath: IndexPath)
func presentContextMenu(for site: Site, with indexPath: IndexPath, completionHandler: @escaping () -> PhotonActionSheet?)
}
extension HomePanelContextMenu {
func presentContextMenu(for indexPath: IndexPath) {
guard let site = getSiteDetails(for: indexPath) else { return }
presentContextMenu(for: site, with: indexPath, completionHandler: {
return self.contextMenu(for: site, with: indexPath)
})
}
func contextMenu(for site: Site, with indexPath: IndexPath) -> PhotonActionSheet? {
guard let actions = self.getContextMenuActions(for: site, with: indexPath) else { return nil }
let contextMenu = PhotonActionSheet(site: site, actions: actions)
contextMenu.modalPresentationStyle = .overFullScreen
contextMenu.modalTransitionStyle = .crossDissolve
return contextMenu
}
func getDefaultContextMenuActions(for site: Site, homePanelDelegate: HomePanelDelegate?) -> [PhotonActionSheetItem]? {
guard let siteURL = URL(string: site.url) else { return nil }
let openInNewTabAction = PhotonActionSheetItem(title: Strings.OpenInNewTabContextMenuTitle, iconString: "quick_action_new_tab") { action in
homePanelDelegate?.homePanelDidRequestToOpenInNewTab(siteURL, isPrivate: false)
}
let openInNewPrivateTabAction = PhotonActionSheetItem(title: Strings.OpenInNewPrivateTabContextMenuTitle, iconString: "quick_action_new_private_tab") { action in
homePanelDelegate?.homePanelDidRequestToOpenInNewTab(siteURL, isPrivate: true)
}
return [openInNewTabAction, openInNewPrivateTabAction]
}
}

View file

@ -0,0 +1,69 @@
/* 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 Foundation
import UIKit
import Shared
/**
* Data for identifying and constructing a HomePanel.
*/
struct HomePanelDescriptor {
let makeViewController: (_ profile: Profile) -> UIViewController
let imageName: String
let accessibilityLabel: String
let accessibilityIdentifier: String
}
class HomePanels {
let enabledPanels = [
HomePanelDescriptor(
makeViewController: { profile in
return ActivityStreamPanel(profile: profile)
},
imageName: "TopSites",
accessibilityLabel: NSLocalizedString("Top sites", comment: "Panel accessibility label"),
accessibilityIdentifier: "HomePanels.TopSites"),
HomePanelDescriptor(
makeViewController: { profile in
let bookmarks = BookmarksPanel()
bookmarks.profile = profile
let controller = UINavigationController(rootViewController: bookmarks)
controller.setNavigationBarHidden(true, animated: false)
// this re-enables the native swipe to pop gesture on UINavigationController for embedded, navigation bar-less UINavigationControllers
// don't ask me why it works though, I've tried to find an answer but can't.
// found here, along with many other places:
// http://luugiathuy.com/2013/11/ios7-interactivepopgesturerecognizer-for-uinavigationcontroller-with-hidden-navigation-bar/
controller.interactivePopGestureRecognizer?.delegate = nil
return controller
},
imageName: "Bookmarks",
accessibilityLabel: NSLocalizedString("Bookmarks", comment: "Panel accessibility label"),
accessibilityIdentifier: "HomePanels.Bookmarks"),
HomePanelDescriptor(
makeViewController: { profile in
let history = HistoryPanel()
history.profile = profile
let controller = UINavigationController(rootViewController: history)
controller.setNavigationBarHidden(true, animated: false)
controller.interactivePopGestureRecognizer?.delegate = nil
return controller
},
imageName: "History",
accessibilityLabel: NSLocalizedString("History", comment: "Panel accessibility label"),
accessibilityIdentifier: "HomePanels.History"),
HomePanelDescriptor(
makeViewController: { profile in
let controller = ReadingListPanel()
controller.profile = profile
return controller
},
imageName: "ReadingList",
accessibilityLabel: NSLocalizedString("Reading list", comment: "Panel accessibility label"),
accessibilityIdentifier: "HomePanels.ReadingList"),
]
}

View file

@ -0,0 +1,97 @@
/* 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 Foundation
import Deferred
import Shared
public let ActivityStreamTopSiteCacheSize: Int32 = 16
private let log = Logger.browserLogger
protocol DataObserver {
var profile: Profile { get }
weak var delegate: DataObserverDelegate? { get set }
func refreshIfNeeded(forceHighlights highlights: Bool, forceTopSites topSites: Bool)
}
protocol DataObserverDelegate: class {
func didInvalidateDataSources(refresh forced: Bool, highlightsRefreshed: Bool, topSitesRefreshed: Bool)
func willInvalidateDataSources(forceHighlights highlights: Bool, forceTopSites topSites: Bool)
}
// Make these delegate methods optional by providing default implementations
extension DataObserverDelegate {
func didInvalidateDataSources(refresh forced: Bool, highlightsRefreshed: Bool, topSitesRefreshed: Bool) {}
func willInvalidateDataSources(forceHighlights highlights: Bool, forceTopSites topSites: Bool) {}
}
open class PanelDataObservers {
var activityStream: DataObserver
init(profile: Profile) {
self.activityStream = ActivityStreamDataObserver(profile: profile)
}
}
class ActivityStreamDataObserver: DataObserver {
let profile: Profile
weak var delegate: DataObserverDelegate?
private var invalidationTime = OneMinuteInMilliseconds * 15
fileprivate let events = [NotificationFirefoxAccountChanged, NotificationProfileDidFinishSyncing, NotificationPrivateDataClearedHistory]
init(profile: Profile) {
self.profile = profile
self.profile.history.setTopSitesCacheSize(ActivityStreamTopSiteCacheSize)
events.forEach { NotificationCenter.default.addObserver(self, selector: #selector(self.notificationReceived(_:)), name: $0, object: nil) }
}
/*
refreshIfNeeded will refresh the underlying caches for both TopSites and Highlights.
By default this will only refresh the highlights if the last fetch is older than 15 mins
By default this will only refresh topSites if KeyTopSitesCacheIsValid is false
*/
func refreshIfNeeded(forceHighlights highlights: Bool, forceTopSites topSites: Bool) {
guard !profile.isShutdown else {
return
}
// Highlights are cached for 15 mins
let userEnabledHighlights = profile.prefs.boolForKey(PrefsKeys.ASRecentHighlightsVisible) ?? true
let lastInvalidationTime = profile.prefs.unsignedLongForKey(PrefsKeys.ASLastInvalidation) ?? 0
let shouldInvalidateHighlights = (highlights || (Date.now() - lastInvalidationTime > invalidationTime)) && userEnabledHighlights
// KeyTopSitesCacheIsValid is false when we want to invalidate. Thats why this logic is so backwards
let shouldInvalidateTopSites = topSites || !(profile.prefs.boolForKey(PrefsKeys.KeyTopSitesCacheIsValid) ?? false)
if !shouldInvalidateTopSites && !shouldInvalidateHighlights {
// There is nothing to refresh. Bye
return
}
self.delegate?.willInvalidateDataSources(forceHighlights: highlights, forceTopSites: topSites)
self.profile.recommendations.repopulate(invalidateTopSites: shouldInvalidateTopSites, invalidateHighlights: shouldInvalidateHighlights).uponQueue(DispatchQueue.main) { _ in
if shouldInvalidateTopSites {
self.profile.prefs.setBool(true, forKey: PrefsKeys.KeyTopSitesCacheIsValid)
}
if shouldInvalidateHighlights {
let newInvalidationTime = shouldInvalidateHighlights ? Date.now() : lastInvalidationTime
self.profile.prefs.setLong(newInvalidationTime, forKey: PrefsKeys.ASLastInvalidation)
}
self.delegate?.didInvalidateDataSources(refresh: highlights || topSites, highlightsRefreshed: shouldInvalidateHighlights, topSitesRefreshed: shouldInvalidateTopSites)
}
}
@objc func notificationReceived(_ notification: Notification) {
switch notification.name {
case NotificationProfileDidFinishSyncing, NotificationFirefoxAccountChanged, NotificationPrivateDataClearedHistory:
refreshIfNeeded(forceHighlights: true, forceTopSites: true)
default:
log.warning("Received unexpected notification \(notification.name)")
}
}
}

View file

@ -0,0 +1,456 @@
/* 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 SnapKit
import Storage
import ReadingList
import Shared
import XCGLogger
private let log = Logger.browserLogger
private struct ReadingListTableViewCellUX {
static let RowHeight: CGFloat = 86
static let ActiveTextColor = UIColor(red: 0.2, green: 0.2, blue: 0.2, alpha: 1.0)
static let DimmedTextColor = UIColor(red: 0.2, green: 0.2, blue: 0.2, alpha: 0.44)
static let ReadIndicatorWidth: CGFloat = 12 // image width
static let ReadIndicatorHeight: CGFloat = 12 // image height
static let ReadIndicatorLeftOffset: CGFloat = 18
static let ReadAccessibilitySpeechPitch: Float = 0.7 // 1.0 default, 0.0 lowest, 2.0 highest
static let TitleLabelTopOffset: CGFloat = 14 - 4
static let TitleLabelLeftOffset: CGFloat = 16 + 16 + 16
static let TitleLabelRightOffset: CGFloat = -40
static let HostnameLabelBottomOffset: CGFloat = 11
static let DeleteButtonBackgroundColor = UIColor(rgb: 0xef4035)
static let DeleteButtonTitleColor = UIColor.white
static let DeleteButtonTitleEdgeInsets = UIEdgeInsets(top: 4, left: 4, bottom: 4, right: 4)
static let MarkAsReadButtonBackgroundColor = UIColor(rgb: 0x2193d1)
static let MarkAsReadButtonTitleColor = UIColor.white
static let MarkAsReadButtonTitleEdgeInsets = UIEdgeInsets(top: 4, left: 4, bottom: 4, right: 4)
// Localizable strings
static let DeleteButtonTitleText = NSLocalizedString("Remove", comment: "Title for the button that removes a reading list item")
static let MarkAsReadButtonTitleText = NSLocalizedString("Mark as Read", comment: "Title for the button that marks a reading list item as read")
static let MarkAsUnreadButtonTitleText = NSLocalizedString("Mark as Unread", comment: "Title for the button that marks a reading list item as unread")
}
private struct ReadingListPanelUX {
// Welcome Screen
static let WelcomeScreenTopPadding: CGFloat = 16
static let WelcomeScreenPadding: CGFloat = 15
static let WelcomeScreenHeaderTextColor = UIColor.darkGray
static let WelcomeScreenItemTextColor = UIColor.gray
static let WelcomeScreenItemWidth = 220
static let WelcomeScreenItemOffset = -20
static let WelcomeScreenCircleWidth = 40
static let WelcomeScreenCircleOffset = 20
static let WelcomeScreenCircleSpacer = 10
}
class ReadingListTableViewCell: UITableViewCell {
var title: String = "Example" {
didSet {
titleLabel.text = title
updateAccessibilityLabel()
}
}
var url: URL = URL(string: "http://www.example.com")! {
didSet {
hostnameLabel.text = simplifiedHostnameFromURL(url)
updateAccessibilityLabel()
}
}
var unread: Bool = true {
didSet {
readStatusImageView.image = UIImage(named: unread ? "MarkAsRead" : "MarkAsUnread")
titleLabel.textColor = unread ? ReadingListTableViewCellUX.ActiveTextColor : ReadingListTableViewCellUX.DimmedTextColor
hostnameLabel.textColor = unread ? ReadingListTableViewCellUX.ActiveTextColor : ReadingListTableViewCellUX.DimmedTextColor
updateAccessibilityLabel()
}
}
let readStatusImageView: UIImageView!
let titleLabel: UILabel!
let hostnameLabel: UILabel!
override init(style: UITableViewCellStyle, reuseIdentifier: String?) {
readStatusImageView = UIImageView()
titleLabel = UILabel()
hostnameLabel = UILabel()
super.init(style: style, reuseIdentifier: reuseIdentifier)
backgroundColor = UIColor.clear
separatorInset = UIEdgeInsets(top: 0, left: 48, bottom: 0, right: 0)
layoutMargins = UIEdgeInsets.zero
preservesSuperviewLayoutMargins = false
contentView.addSubview(readStatusImageView)
readStatusImageView.contentMode = UIViewContentMode.scaleAspectFit
readStatusImageView.snp.makeConstraints { (make) -> Void in
make.width.equalTo(ReadingListTableViewCellUX.ReadIndicatorWidth)
make.height.equalTo(ReadingListTableViewCellUX.ReadIndicatorHeight)
make.centerY.equalTo(self.contentView)
make.leading.equalTo(self.contentView).offset(ReadingListTableViewCellUX.ReadIndicatorLeftOffset)
}
contentView.addSubview(titleLabel)
contentView.addSubview(hostnameLabel)
titleLabel.textColor = ReadingListTableViewCellUX.ActiveTextColor
titleLabel.numberOfLines = 2
titleLabel.snp.makeConstraints { (make) -> Void in
make.top.equalTo(self.contentView).offset(ReadingListTableViewCellUX.TitleLabelTopOffset)
make.leading.equalTo(self.contentView).offset(ReadingListTableViewCellUX.TitleLabelLeftOffset)
make.trailing.equalTo(self.contentView).offset(ReadingListTableViewCellUX.TitleLabelRightOffset) // TODO Not clear from ux spec
make.bottom.lessThanOrEqualTo(hostnameLabel.snp.top).priority(1000)
}
hostnameLabel.textColor = ReadingListTableViewCellUX.ActiveTextColor
hostnameLabel.numberOfLines = 1
hostnameLabel.snp.makeConstraints { (make) -> Void in
make.bottom.equalTo(self.contentView).offset(-ReadingListTableViewCellUX.HostnameLabelBottomOffset)
make.leading.trailing.equalTo(self.titleLabel)
}
setupDynamicFonts()
}
func setupDynamicFonts() {
titleLabel.font = DynamicFontHelper.defaultHelper.DeviceFont
hostnameLabel.font = DynamicFontHelper.defaultHelper.DeviceFontSmallLight
}
override func prepareForReuse() {
super.prepareForReuse()
setupDynamicFonts()
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
let prefixesToSimplify = ["www.", "mobile.", "m.", "blog."]
fileprivate func simplifiedHostnameFromURL(_ url: URL) -> String {
let hostname = url.host ?? ""
for prefix in prefixesToSimplify {
if hostname.hasPrefix(prefix) {
return hostname.substring(from: hostname.characters.index(hostname.startIndex, offsetBy: prefix.characters.count))
}
}
return hostname
}
fileprivate func updateAccessibilityLabel() {
if let hostname = hostnameLabel.text,
let title = titleLabel.text {
let unreadStatus = unread ? NSLocalizedString("unread", comment: "Accessibility label for unread article in reading list. It's a past participle - functions as an adjective.") : NSLocalizedString("read", comment: "Accessibility label for read article in reading list. It's a past participle - functions as an adjective.")
let string = "\(title), \(unreadStatus), \(hostname)"
var label: AnyObject
if !unread {
// mimic light gray visual dimming by "dimming" the speech by reducing pitch
let lowerPitchString = NSMutableAttributedString(string: string as String)
lowerPitchString.addAttribute(UIAccessibilitySpeechAttributePitch, value: NSNumber(value: ReadingListTableViewCellUX.ReadAccessibilitySpeechPitch as Float), range: NSRange(location: 0, length: lowerPitchString.length))
label = NSAttributedString(attributedString: lowerPitchString)
} else {
label = string as AnyObject
}
// need to use KVC as accessibilityLabel is of type String! and cannot be set to NSAttributedString other way than this
// see bottom of page 121 of the PDF slides of WWDC 2012 "Accessibility for iOS" session for indication that this is OK by Apple
// also this combined with Swift's strictness is why we cannot simply override accessibilityLabel and return the label directly...
setValue(label, forKey: "accessibilityLabel")
}
}
}
class ReadingListPanel: UITableViewController, HomePanel {
weak var homePanelDelegate: HomePanelDelegate?
var profile: Profile!
fileprivate lazy var longPressRecognizer: UILongPressGestureRecognizer = {
return UILongPressGestureRecognizer(target: self, action: #selector(ReadingListPanel.longPress(_:)))
}()
fileprivate lazy var emptyStateOverlayView: UIView = self.createEmptyStateOverview()
fileprivate var records: [ReadingListClientRecord]?
init() {
super.init(nibName: nil, bundle: nil)
NotificationCenter.default.addObserver(self, selector: #selector(ReadingListPanel.notificationReceived(_:)), name: NotificationFirefoxAccountChanged, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(ReadingListPanel.notificationReceived(_:)), name: NotificationDynamicFontChanged, object: nil)
}
required init!(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
tableView.addGestureRecognizer(longPressRecognizer)
tableView.accessibilityIdentifier = "ReadingTable"
tableView.estimatedRowHeight = ReadingListTableViewCellUX.RowHeight
tableView.rowHeight = UITableViewAutomaticDimension
tableView.cellLayoutMarginsFollowReadableWidth = false
tableView.separatorInset = UIEdgeInsets.zero
tableView.layoutMargins = UIEdgeInsets.zero
tableView.separatorColor = UIConstants.SeparatorColor
tableView.register(ReadingListTableViewCell.self, forCellReuseIdentifier: "ReadingListTableViewCell")
// Set an empty footer to prevent empty cells from appearing in the list.
tableView.tableFooterView = UIView()
view.backgroundColor = UIConstants.PanelBackgroundColor
if let result = profile.readingList?.getAvailableRecords(), result.isSuccess {
records = result.successValue
// If no records have been added yet, we display the empty state
if records?.count == 0 {
tableView.isScrollEnabled = false
view.addSubview(emptyStateOverlayView)
}
}
}
func notificationReceived(_ notification: Notification) {
switch notification.name {
case NotificationFirefoxAccountChanged:
refreshReadingList()
break
case NotificationDynamicFontChanged:
if emptyStateOverlayView.superview != nil {
emptyStateOverlayView.removeFromSuperview()
}
emptyStateOverlayView = createEmptyStateOverview()
refreshReadingList()
break
default:
// no need to do anything at all
log.warning("Received unexpected notification \(notification.name)")
break
}
}
func refreshReadingList() {
let prevNumberOfRecords = records?.count
if let result = profile.readingList?.getAvailableRecords(), result.isSuccess {
records = result.successValue
if records?.count == 0 {
tableView.isScrollEnabled = false
if emptyStateOverlayView.superview == nil {
view.addSubview(emptyStateOverlayView)
}
} else {
if prevNumberOfRecords == 0 {
tableView.isScrollEnabled = true
emptyStateOverlayView.removeFromSuperview()
}
}
self.tableView.reloadData()
}
}
fileprivate func createEmptyStateOverview() -> UIView {
let overlayView = UIScrollView(frame: tableView.bounds)
overlayView.backgroundColor = UIColor.white
// Unknown why this does not work with autolayout
overlayView.autoresizingMask = [UIViewAutoresizing.flexibleHeight, UIViewAutoresizing.flexibleWidth]
let containerView = UIView()
overlayView.addSubview(containerView)
let welcomeLabel = UILabel()
containerView.addSubview(welcomeLabel)
welcomeLabel.text = NSLocalizedString("Welcome to your Reading List", comment: "See http://mzl.la/1LXbDOL")
welcomeLabel.textAlignment = NSTextAlignment.center
welcomeLabel.font = DynamicFontHelper.defaultHelper.DeviceFontSmallBold
welcomeLabel.textColor = ReadingListPanelUX.WelcomeScreenHeaderTextColor
welcomeLabel.adjustsFontSizeToFitWidth = true
welcomeLabel.snp.makeConstraints { make in
make.centerX.equalTo(containerView)
make.width.equalTo(ReadingListPanelUX.WelcomeScreenItemWidth + ReadingListPanelUX.WelcomeScreenCircleSpacer + ReadingListPanelUX.WelcomeScreenCircleWidth)
make.top.equalTo(containerView)
// Sets proper center constraint for iPhones in landscape.
make.centerY.lessThanOrEqualTo(overlayView.snp.centerY).offset(-40).priority(1000)
}
let readerModeLabel = UILabel()
containerView.addSubview(readerModeLabel)
readerModeLabel.text = NSLocalizedString("Open articles in Reader View by tapping the book icon when it appears in the title bar.", comment: "See http://mzl.la/1LXbDOL")
readerModeLabel.font = DynamicFontHelper.defaultHelper.DeviceFontSmallLight
readerModeLabel.textColor = ReadingListPanelUX.WelcomeScreenItemTextColor
readerModeLabel.numberOfLines = 0
readerModeLabel.snp.makeConstraints { make in
make.top.equalTo(welcomeLabel.snp.bottom).offset(ReadingListPanelUX.WelcomeScreenPadding)
make.leading.equalTo(welcomeLabel.snp.leading)
make.width.equalTo(ReadingListPanelUX.WelcomeScreenItemWidth)
}
let readerModeImageView = UIImageView(image: UIImage(named: "ReaderModeCircle"))
containerView.addSubview(readerModeImageView)
readerModeImageView.snp.makeConstraints { make in
make.centerY.equalTo(readerModeLabel)
make.trailing.equalTo(welcomeLabel.snp.trailing)
}
let readingListLabel = UILabel()
containerView.addSubview(readingListLabel)
readingListLabel.text = NSLocalizedString("Save pages to your Reading List by tapping the book plus icon in the Reader View controls.", comment: "See http://mzl.la/1LXbDOL")
readingListLabel.font = DynamicFontHelper.defaultHelper.DeviceFontSmallLight
readingListLabel.textColor = ReadingListPanelUX.WelcomeScreenItemTextColor
readingListLabel.numberOfLines = 0
readingListLabel.snp.makeConstraints { make in
make.top.equalTo(readerModeLabel.snp.bottom).offset(ReadingListPanelUX.WelcomeScreenPadding)
make.leading.equalTo(welcomeLabel.snp.leading)
make.width.equalTo(ReadingListPanelUX.WelcomeScreenItemWidth)
make.bottom.equalTo(overlayView).offset(-20) // making AutoLayout compute the overlayView's contentSize
}
let readingListImageView = UIImageView(image: UIImage(named: "AddToReadingListCircle"))
containerView.addSubview(readingListImageView)
readingListImageView.snp.makeConstraints { make in
make.centerY.equalTo(readingListLabel)
make.trailing.equalTo(welcomeLabel.snp.trailing)
}
containerView.snp.makeConstraints { make in
// Let the container wrap around the content
make.left.equalTo(welcomeLabel).offset(ReadingListPanelUX.WelcomeScreenItemOffset)
make.right.equalTo(welcomeLabel).offset(ReadingListPanelUX.WelcomeScreenCircleOffset)
// And then center it in the overlay view that sits on top of the UITableView
make.centerX.equalTo(overlayView)
}
return overlayView
}
@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 numberOfSections(in tableView: UITableView) -> Int {
return 1
}
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return records?.count ?? 0
}
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "ReadingListTableViewCell", for: indexPath) as! ReadingListTableViewCell
if let record = records?[indexPath.row] {
cell.title = record.title
cell.url = URL(string: record.url)!
cell.unread = record.unread
}
return cell
}
override func tableView(_ tableView: UITableView, editActionsForRowAt indexPath: IndexPath) -> [UITableViewRowAction]? {
guard let record = records?[indexPath.row] else {
return []
}
let delete = UITableViewRowAction(style: .normal, title: ReadingListTableViewCellUX.DeleteButtonTitleText) { [weak self] action, index in
self?.deleteItem(atIndex: index)
}
delete.backgroundColor = ReadingListTableViewCellUX.DeleteButtonBackgroundColor
let toggleText = record.unread ? ReadingListTableViewCellUX.MarkAsReadButtonTitleText : ReadingListTableViewCellUX.MarkAsUnreadButtonTitleText
let unreadToggle = UITableViewRowAction(style: .normal, title: toggleText.stringSplitWithNewline()) { [weak self] (action, index) in
self?.toggleItem(atIndex: index)
}
unreadToggle.backgroundColor = ReadingListTableViewCellUX.MarkAsReadButtonBackgroundColor
return [unreadToggle, delete]
}
override func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool {
// the cells you would like the actions to appear needs to be editable
return true
}
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: false)
if let record = records?[indexPath.row], let url = URL(string: record.url), let encodedURL = url.encodeReaderModeURL(WebServer.sharedInstance.baseReaderModeURL()) {
// Mark the item as read
profile.readingList?.updateRecord(record, unread: false)
// Reading list items are closest in concept to bookmarks.
let visitType = VisitType.bookmark
homePanelDelegate?.homePanel(self, didSelectURL: encodedURL, visitType: visitType)
UnifiedTelemetry.recordEvent(category: .action, method: .open, object: .readingListItem)
}
}
fileprivate func deleteItem(atIndex indexPath: IndexPath) {
if let record = records?[indexPath.row] {
UnifiedTelemetry.recordEvent(category: .action, method: .delete, object: .readingListItem, value: .readingListPanel)
if let result = profile.readingList?.deleteRecord(record), result.isSuccess {
records?.remove(at: indexPath.row)
tableView.deleteRows(at: [indexPath], with: UITableViewRowAnimation.automatic)
// reshow empty state if no records left
if records?.count == 0 {
view.addSubview(emptyStateOverlayView)
}
}
}
}
fileprivate func toggleItem(atIndex indexPath: IndexPath) {
if let record = records?[indexPath.row] {
UnifiedTelemetry.recordEvent(category: .action, method: .tap, object: .readingListItem, value: !record.unread ? .markAsUnread : .markAsRead, extras: [ "from": "reading-list-panel" ])
if let result = profile.readingList?.updateRecord(record, unread: !record.unread), result.isSuccess {
// TODO This is a bit odd because the success value of the update is an optional optional Record
if let successValue = result.successValue, let updatedRecord = successValue {
records?[indexPath.row] = updatedRecord
tableView.reloadRows(at: [indexPath], with: UITableViewRowAnimation.automatic)
}
}
}
}
}
extension ReadingListPanel: 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 record = records?[indexPath.row] else { return nil }
return Site(url: record.url, title: record.title)
}
func getContextMenuActions(for site: Site, with indexPath: IndexPath) -> [PhotonActionSheetItem]? {
guard var actions = getDefaultContextMenuActions(for: site, homePanelDelegate: homePanelDelegate) else { return nil }
let removeAction: PhotonActionSheetItem = PhotonActionSheetItem(title: Strings.RemoveContextMenuTitle, iconString: "action_remove", handler: { action in
self.deleteItem(atIndex: indexPath)
})
actions.append(removeAction)
return actions
}
}

View file

@ -0,0 +1,178 @@
/* 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 Shared
import Storage
import XCGLogger
import Deferred
private let log = Logger.browserLogger
struct RecentlyClosedPanelUX {
static let IconSize = CGSize(width: 23, height: 23)
static let IconBorderColor = UIColor(white: 0, alpha: 0.1)
static let IconBorderWidth: CGFloat = 0.5
}
class RecentlyClosedTabsPanel: UIViewController, HomePanel {
weak var homePanelDelegate: HomePanelDelegate?
var profile: Profile!
fileprivate lazy var recentlyClosedHeader: UILabel = {
let headerLabel = UILabel()
headerLabel.text = Strings.RecentlyClosedTabsPanelTitle
headerLabel.font = DynamicFontHelper.defaultHelper.DeviceFontHistoryPanel
headerLabel.textAlignment = .center
headerLabel.backgroundColor = .white
return headerLabel
}()
fileprivate var tableViewController = RecentlyClosedTabsPanelSiteTableViewController()
fileprivate lazy var historyBackButton: HistoryBackButton = {
let button = HistoryBackButton()
button.addTarget(self, action: #selector(RecentlyClosedTabsPanel.historyBackButtonWasTapped), for: .touchUpInside)
return button
}()
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .white
tableViewController.profile = self.profile
tableViewController.homePanelDelegate = homePanelDelegate
tableViewController.recentlyClosedTabsPanel = self
self.addChildViewController(tableViewController)
self.view.addSubview(tableViewController.view)
self.view.addSubview(historyBackButton)
self.view.addSubview(recentlyClosedHeader)
historyBackButton.snp.makeConstraints { make in
make.top.left.right.equalTo(self.view)
make.height.equalTo(50)
make.bottom.equalTo(recentlyClosedHeader.snp.top)
}
recentlyClosedHeader.snp.makeConstraints { make in
make.top.equalTo(historyBackButton.snp.bottom)
make.height.equalTo(20)
make.bottom.equalTo(tableViewController.view.snp.top).offset(-10)
make.left.right.equalTo(self.view)
}
tableViewController.view.snp.makeConstraints { make in
make.left.right.bottom.equalTo(self.view)
}
tableViewController.didMove(toParentViewController: self)
}
@objc fileprivate func historyBackButtonWasTapped(_ gestureRecognizer: UITapGestureRecognizer) {
_ = self.navigationController?.popViewController(animated: true)
}
}
class RecentlyClosedTabsPanelSiteTableViewController: SiteTableViewController {
weak var homePanelDelegate: HomePanelDelegate?
var recentlyClosedTabs: [ClosedTab] = []
weak var recentlyClosedTabsPanel: RecentlyClosedTabsPanel?
fileprivate lazy var longPressRecognizer: UILongPressGestureRecognizer = {
return UILongPressGestureRecognizer(target: self, action: #selector(RecentlyClosedTabsPanelSiteTableViewController.longPress(_:)))
}()
init() {
super.init(nibName: nil, bundle: nil)
}
override func viewDidLoad() {
super.viewDidLoad()
tableView.addGestureRecognizer(longPressRecognizer)
tableView.accessibilityIdentifier = "Recently Closed Tabs List"
self.recentlyClosedTabs = profile.recentlyClosedTabs.tabs
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
@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, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = super.tableView(tableView, cellForRowAt: indexPath)
guard let twoLineCell = cell as? TwoLineTableViewCell else {
return cell
}
let tab = recentlyClosedTabs[indexPath.row]
let displayURL = tab.url.displayURL ?? tab.url
twoLineCell.setLines(tab.title, detailText: displayURL.absoluteDisplayString)
let site: Favicon? = (tab.faviconURL != nil) ? Favicon(url: tab.faviconURL!, type: .guess) : nil
cell.imageView!.layer.borderColor = RecentlyClosedPanelUX.IconBorderColor.cgColor
cell.imageView!.layer.borderWidth = RecentlyClosedPanelUX.IconBorderWidth
cell.imageView?.setIcon(site, forURL: displayURL, completed: { (color, url) in
if url == displayURL {
cell.imageView?.image = cell.imageView?.image?.createScaled(RecentlyClosedPanelUX.IconSize)
cell.imageView?.contentMode = .center
cell.imageView?.backgroundColor = color
}
})
return cell
}
func tableView(_ tableView: UITableView, didSelectRowAtIndexPath indexPath: IndexPath) {
guard let homePanelDelegate = homePanelDelegate,
let recentlyClosedTabsPanel = recentlyClosedTabsPanel else {
log.warning("No site or no URL when selecting row.")
return
}
let visitType = VisitType.typed // Means History, too.
homePanelDelegate.homePanel(recentlyClosedTabsPanel, didSelectURL: recentlyClosedTabs[indexPath.row].url, visitType: visitType)
}
override func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat {
return 0
}
// Functions that deal with showing header rows.
func numberOfSectionsInTableView(_ tableView: UITableView) -> Int {
return 1
}
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return profile.recentlyClosedTabs.tabs.count
}
}
extension RecentlyClosedTabsPanelSiteTableViewController: 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? {
let closedTab = recentlyClosedTabs[indexPath.row]
let site: Site
if let title = closedTab.title {
site = Site(url: String(describing: closedTab.url), title: title)
} else {
site = Site(url: String(describing: closedTab.url), title: "")
}
return site
}
func getContextMenuActions(for site: Site, with indexPath: IndexPath) -> [PhotonActionSheetItem]? {
return getDefaultContextMenuActions(for: site, homePanelDelegate: homePanelDelegate)
}
}

View file

@ -0,0 +1,610 @@
/* 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 Account
import Shared
import SnapKit
import Storage
import Sync
import XCGLogger
private let log = Logger.browserLogger
private struct RemoteTabsPanelUX {
static let HeaderHeight = SiteTableViewControllerUX.RowHeight // Not HeaderHeight!
static let RowHeight = SiteTableViewControllerUX.RowHeight
static let HeaderBackgroundColor = UIColor(rgb: 0xf8f8f8)
static let EmptyStateTitleTextColor = UIColor.darkGray
static let EmptyStateInstructionsTextColor = UIColor.gray
static let EmptyStateInstructionsWidth = 170
static let EmptyStateTopPaddingInBetweenItems: CGFloat = 15 // UX TODO I set this to 8 so that it all fits on landscape
static let EmptyStateSignInButtonColor = UIColor(red: 0.3, green: 0.62, blue: 1, alpha: 1)
static let EmptyStateSignInButtonTitleColor = UIColor.white
static let EmptyStateSignInButtonCornerRadius: CGFloat = 4
static let EmptyStateSignInButtonHeight = 44
static let EmptyStateSignInButtonWidth = 200
// Backup and active strings added in Bug 1205294.
static let EmptyStateInstructionsSyncTabsPasswordsBookmarksString = NSLocalizedString("Sync your tabs, bookmarks, passwords and more.", comment: "Text displayed when the Sync home panel is empty, describing the features provided by Sync to invite the user to log in.")
static let EmptyStateInstructionsSyncTabsPasswordsString = NSLocalizedString("Sync your tabs, passwords and more.", comment: "Text displayed when the Sync home panel is empty, describing the features provided by Sync to invite the user to log in.")
static let EmptyStateInstructionsGetTabsBookmarksPasswordsString = NSLocalizedString("Get your open tabs, bookmarks, and passwords from your other devices.", comment: "A re-worded offer about Sync, displayed when the Sync home panel is empty, that emphasizes one-way data transfer, not syncing.")
static let HistoryTableViewHeaderChevronInset: CGFloat = 10
static let HistoryTableViewHeaderChevronSize: CGFloat = 20
static let HistoryTableViewHeaderChevronLineWidth: CGFloat = 3.0
}
private let RemoteClientIdentifier = "RemoteClient"
private let RemoteTabIdentifier = "RemoteTab"
class RemoteTabsPanel: UIViewController, HomePanel {
weak var homePanelDelegate: HomePanelDelegate?
fileprivate lazy var tableViewController: RemoteTabsTableViewController = RemoteTabsTableViewController()
fileprivate lazy var historyBackButton: HistoryBackButton = {
let button = HistoryBackButton()
button.addTarget(self, action: #selector(RemoteTabsPanel.historyBackButtonWasTapped), for: .touchUpInside)
return button
}()
var profile: Profile!
init() {
super.init(nibName: nil, bundle: nil)
NotificationCenter.default.addObserver(self, selector: #selector(RemoteTabsPanel.notificationReceived(_:)), name: NotificationFirefoxAccountChanged, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(RemoteTabsPanel.notificationReceived(_:)), name: NotificationProfileDidFinishSyncing, object: nil)
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
tableViewController.profile = profile
tableViewController.remoteTabsPanel = self
view.backgroundColor = UIConstants.PanelBackgroundColor
addChildViewController(tableViewController)
self.view.addSubview(tableViewController.view)
self.view.addSubview(historyBackButton)
historyBackButton.snp.makeConstraints { make in
make.top.left.right.equalTo(self.view)
make.height.equalTo(50)
make.bottom.equalTo(tableViewController.view.snp.top)
}
tableViewController.view.snp.makeConstraints { make in
make.top.equalTo(historyBackButton.snp.bottom)
make.left.right.bottom.equalTo(self.view)
}
tableViewController.didMove(toParentViewController: self)
}
func notificationReceived(_ notification: Notification) {
switch notification.name {
case NotificationFirefoxAccountChanged, NotificationProfileDidFinishSyncing:
DispatchQueue.main.async {
print(notification.name)
self.tableViewController.refreshTabs()
}
break
default:
// no need to do anything at all
log.warning("Received unexpected notification \(notification.name)")
break
}
}
@objc fileprivate func historyBackButtonWasTapped(_ gestureRecognizer: UITapGestureRecognizer) {
_ = self.navigationController?.popViewController(animated: true)
}
}
enum RemoteTabsError {
case notLoggedIn
case noClients
case noTabs
case failedToSync
func localizedString() -> String {
switch self {
case .notLoggedIn:
return "" // This does not have a localized string because we have a whole specific screen for it.
case .noClients:
return Strings.EmptySyncedTabsPanelNullStateDescription
case .noTabs:
return NSLocalizedString("You dont have any tabs open in Firefox on your other devices.", comment: "Error message in the remote tabs panel")
case .failedToSync:
return NSLocalizedString("There was a problem accessing tabs from your other devices. Try again in a few moments.", comment: "Error message in the remote tabs panel")
}
}
}
protocol RemoteTabsPanelDataSource: UITableViewDataSource, UITableViewDelegate {
}
class RemoteTabsPanelClientAndTabsDataSource: NSObject, RemoteTabsPanelDataSource {
weak var homePanel: HomePanel?
fileprivate var clientAndTabs: [ClientAndTabs]
init(homePanel: HomePanel, clientAndTabs: [ClientAndTabs]) {
self.homePanel = homePanel
self.clientAndTabs = clientAndTabs
}
func numberOfSections(in tableView: UITableView) -> Int {
return self.clientAndTabs.count
}
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return self.clientAndTabs[section].tabs.count
}
func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat {
return RemoteTabsPanelUX.HeaderHeight
}
func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? {
let clientTabs = self.clientAndTabs[section]
let client = clientTabs.client
let view = tableView.dequeueReusableHeaderFooterView(withIdentifier: RemoteClientIdentifier) as! TwoLineHeaderFooterView
view.frame = CGRect(x: 0, y: 0, width: tableView.frame.width, height: RemoteTabsPanelUX.HeaderHeight)
view.textLabel?.text = client.name
view.contentView.backgroundColor = RemoteTabsPanelUX.HeaderBackgroundColor
/*
* A note on timestamps.
* We have access to two timestamps here: the timestamp of the remote client record,
* and the set of timestamps of the client's tabs.
* Neither is "last synced". The client record timestamp changes whenever the remote
* client uploads its record (i.e., infrequently), but also whenever another device
* sends a command to that client -- which can be much later than when that client
* last synced.
* The client's tabs haven't necessarily changed, but it can still have synced.
* Ideally, we should save and use the modified time of the tabs record itself.
* This will be the real time that the other client uploaded tabs.
*/
let timestamp = clientTabs.approximateLastSyncTime()
let label = NSLocalizedString("Last synced: %@", comment: "Remote tabs last synced time. Argument is the relative date string.")
view.detailTextLabel?.text = String(format: label, Date.fromTimestamp(timestamp).toRelativeTimeString())
let image: UIImage?
if client.type == "desktop" {
image = UIImage(named: "deviceTypeDesktop")
image?.accessibilityLabel = NSLocalizedString("computer", comment: "Accessibility label for Desktop Computer (PC) image in remote tabs list")
} else {
image = UIImage(named: "deviceTypeMobile")
image?.accessibilityLabel = NSLocalizedString("mobile device", comment: "Accessibility label for Mobile Device image in remote tabs list")
}
view.imageView.image = image
view.mergeAccessibilityLabels()
return view
}
fileprivate func tabAtIndexPath(_ indexPath: IndexPath) -> RemoteTab {
return clientAndTabs[indexPath.section].tabs[indexPath.item]
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: RemoteTabIdentifier, for: indexPath) as! TwoLineTableViewCell
let tab = tabAtIndexPath(indexPath)
cell.setLines(tab.title, detailText: tab.URL.absoluteString)
// TODO: Bug 1144765 - Populate image with cached favicons.
return cell
}
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: false)
let tab = tabAtIndexPath(indexPath)
if let homePanel = self.homePanel {
// It's not a bookmark, so let's call it Typed (which means History, too).
homePanel.homePanelDelegate?.homePanel(homePanel, didSelectURL: tab.URL, visitType: VisitType.typed)
}
}
}
// MARK: -
class RemoteTabsPanelErrorDataSource: NSObject, RemoteTabsPanelDataSource {
weak var homePanel: HomePanel?
var error: RemoteTabsError
var notLoggedCell: UITableViewCell?
init(homePanel: HomePanel, error: RemoteTabsError) {
self.homePanel = homePanel
self.error = error
self.notLoggedCell = nil
}
func numberOfSections(in tableView: UITableView) -> Int {
return 1
}
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return 1
}
func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
if let cell = self.notLoggedCell {
cell.updateConstraints()
}
return tableView.bounds.height
}
func tableView(_ tableView: UITableView, heightForFooterInSection section: Int) -> CGFloat {
// Making the footer height as small as possible because it will disable button tappability if too high.
return 1
}
func tableView(_ tableView: UITableView, viewForFooterInSection section: Int) -> UIView? {
return UIView()
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
switch error {
case .notLoggedIn:
let cell = RemoteTabsNotLoggedInCell(homePanel: homePanel)
self.notLoggedCell = cell
return cell
default:
let cell = RemoteTabsErrorCell(error: self.error)
self.notLoggedCell = nil
return cell
}
}
}
// MARK: -
class RemoteTabsErrorCell: UITableViewCell {
static let Identifier = "RemoteTabsErrorCell"
init(error: RemoteTabsError) {
super.init(style: .default, reuseIdentifier: RemoteTabsErrorCell.Identifier)
separatorInset = UIEdgeInsets(top: 0, left: 1000, bottom: 0, right: 0)
let containerView = UIView()
contentView.addSubview(containerView)
let imageView = UIImageView()
imageView.image = UIImage(named: "emptySync")
containerView.addSubview(imageView)
imageView.snp.makeConstraints { (make) -> Void in
make.top.equalTo(containerView)
make.centerX.equalTo(containerView)
}
let titleLabel = UILabel()
titleLabel.font = DynamicFontHelper.defaultHelper.DeviceFont
titleLabel.text = Strings.EmptySyncedTabsPanelStateTitle
titleLabel.textAlignment = NSTextAlignment.center
titleLabel.textColor = RemoteTabsPanelUX.EmptyStateTitleTextColor
containerView.addSubview(titleLabel)
let instructionsLabel = UILabel()
instructionsLabel.font = DynamicFontHelper.defaultHelper.DeviceFontSmallLight
instructionsLabel.text = error.localizedString()
instructionsLabel.textAlignment = NSTextAlignment.center
instructionsLabel.textColor = RemoteTabsPanelUX.EmptyStateInstructionsTextColor
instructionsLabel.numberOfLines = 0
containerView.addSubview(instructionsLabel)
titleLabel.snp.makeConstraints { make in
make.top.equalTo(imageView.snp.bottom).offset(RemoteTabsPanelUX.EmptyStateTopPaddingInBetweenItems)
make.centerX.equalTo(imageView)
}
instructionsLabel.snp.makeConstraints { make in
make.top.equalTo(titleLabel.snp.bottom).offset(RemoteTabsPanelUX.EmptyStateTopPaddingInBetweenItems / 2)
make.centerX.equalTo(containerView)
make.width.equalTo(RemoteTabsPanelUX.EmptyStateInstructionsWidth)
}
containerView.snp.makeConstraints { make in
// Let the container wrap around the content
make.top.equalTo(imageView.snp.top)
make.left.bottom.right.equalTo(instructionsLabel)
// And then center it in the overlay view that sits on top of the UITableView
make.centerX.equalTo(contentView)
// Sets proper top constraint for iPhone 6 in portait and for iPad.
make.centerY.equalTo(contentView.snp.centerY).offset(HomePanelUX.EmptyTabContentOffset).priority(100)
// Sets proper top constraint for iPhone 4, 5 in portrait.
make.top.greaterThanOrEqualTo(contentView.snp.top).offset(20).priority(1000)
}
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
// MARK: -
class RemoteTabsNotLoggedInCell: UITableViewCell {
static let Identifier = "RemoteTabsNotLoggedInCell"
var homePanel: HomePanel?
var instructionsLabel: UILabel
var signInButton: UIButton
var titleLabel: UILabel
var emptyStateImageView: UIImageView
init(homePanel: HomePanel?) {
let titleLabel = UILabel()
let instructionsLabel = UILabel()
let signInButton = UIButton()
let imageView = UIImageView()
self.instructionsLabel = instructionsLabel
self.signInButton = signInButton
self.titleLabel = titleLabel
self.emptyStateImageView = imageView
super.init(style: .default, reuseIdentifier: RemoteTabsErrorCell.Identifier)
self.homePanel = homePanel
let createAnAccountButton = UIButton(type: .system)
imageView.image = UIImage(named: "emptySync")
contentView.addSubview(imageView)
titleLabel.font = DynamicFontHelper.defaultHelper.DeviceFont
titleLabel.text = Strings.EmptySyncedTabsPanelStateTitle
titleLabel.textAlignment = NSTextAlignment.center
titleLabel.textColor = RemoteTabsPanelUX.EmptyStateTitleTextColor
contentView.addSubview(titleLabel)
instructionsLabel.font = DynamicFontHelper.defaultHelper.DeviceFontSmallLight
instructionsLabel.text = Strings.EmptySyncedTabsPanelStateDescription
instructionsLabel.textAlignment = NSTextAlignment.center
instructionsLabel.textColor = RemoteTabsPanelUX.EmptyStateInstructionsTextColor
instructionsLabel.numberOfLines = 0
contentView.addSubview(instructionsLabel)
signInButton.backgroundColor = RemoteTabsPanelUX.EmptyStateSignInButtonColor
signInButton.setTitle(NSLocalizedString("Sign in", comment: "See http://mzl.la/1Qtkf0j"), for: UIControlState())
signInButton.setTitleColor(RemoteTabsPanelUX.EmptyStateSignInButtonTitleColor, for: UIControlState())
signInButton.titleLabel?.font = UIFont.preferredFont(forTextStyle: UIFontTextStyle.subheadline)
signInButton.layer.cornerRadius = RemoteTabsPanelUX.EmptyStateSignInButtonCornerRadius
signInButton.clipsToBounds = true
signInButton.addTarget(self, action: #selector(RemoteTabsNotLoggedInCell.SELsignIn), for: UIControlEvents.touchUpInside)
contentView.addSubview(signInButton)
createAnAccountButton.setTitle(NSLocalizedString("Create an account", comment: "See http://mzl.la/1Qtkf0j"), for: UIControlState())
createAnAccountButton.titleLabel?.font = UIFont.preferredFont(forTextStyle: UIFontTextStyle.caption1)
createAnAccountButton.addTarget(self, action: #selector(RemoteTabsNotLoggedInCell.SELcreateAnAccount), for: UIControlEvents.touchUpInside)
contentView.addSubview(createAnAccountButton)
imageView.snp.makeConstraints { (make) -> Void in
make.centerX.equalTo(instructionsLabel)
// Sets proper top constraint for iPhone 6 in portait and for iPad.
make.centerY.equalTo(contentView).offset(HomePanelUX.EmptyTabContentOffset + 30).priority(100)
// Sets proper top constraint for iPhone 4, 5 in portrait.
make.top.greaterThanOrEqualTo(contentView.snp.top).priority(1000)
}
titleLabel.snp.makeConstraints { make in
make.top.equalTo(imageView.snp.bottom).offset(RemoteTabsPanelUX.EmptyStateTopPaddingInBetweenItems)
make.centerX.equalTo(imageView)
}
createAnAccountButton.snp.makeConstraints { (make) -> Void in
make.centerX.equalTo(signInButton)
make.top.equalTo(signInButton.snp.bottom).offset(RemoteTabsPanelUX.EmptyStateTopPaddingInBetweenItems)
}
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
@objc fileprivate func SELsignIn() {
if let homePanel = self.homePanel {
homePanel.homePanelDelegate?.homePanelDidRequestToSignIn(homePanel)
}
}
@objc fileprivate func SELcreateAnAccount() {
if let homePanel = self.homePanel {
homePanel.homePanelDelegate?.homePanelDidRequestToCreateAccount(homePanel)
}
}
override func updateConstraints() {
if UIInterfaceOrientationIsLandscape(UIApplication.shared.statusBarOrientation) && !(DeviceInfo.deviceModel().range(of: "iPad") != nil) {
instructionsLabel.snp.remakeConstraints { make in
make.top.equalTo(titleLabel.snp.bottom).offset(RemoteTabsPanelUX.EmptyStateTopPaddingInBetweenItems)
make.width.equalTo(RemoteTabsPanelUX.EmptyStateInstructionsWidth)
// Sets proper landscape layout for bigger phones: iPhone 6 and on.
make.left.lessThanOrEqualTo(contentView.snp.left).offset(80).priority(100)
// Sets proper landscape layout for smaller phones: iPhone 4 & 5.
make.right.lessThanOrEqualTo(contentView.snp.centerX).offset(-30).priority(1000)
}
signInButton.snp.remakeConstraints { make in
make.height.equalTo(RemoteTabsPanelUX.EmptyStateSignInButtonHeight)
make.width.equalTo(RemoteTabsPanelUX.EmptyStateSignInButtonWidth)
make.centerY.equalTo(emptyStateImageView).offset(2*RemoteTabsPanelUX.EmptyStateTopPaddingInBetweenItems)
// Sets proper landscape layout for bigger phones: iPhone 6 and on.
make.right.greaterThanOrEqualTo(contentView.snp.right).offset(-70).priority(100)
// Sets proper landscape layout for smaller phones: iPhone 4 & 5.
make.left.greaterThanOrEqualTo(contentView.snp.centerX).offset(10).priority(1000)
}
} else {
instructionsLabel.snp.remakeConstraints { make in
make.top.equalTo(titleLabel.snp.bottom).offset(RemoteTabsPanelUX.EmptyStateTopPaddingInBetweenItems)
make.centerX.equalTo(contentView)
make.width.equalTo(RemoteTabsPanelUX.EmptyStateInstructionsWidth)
}
signInButton.snp.remakeConstraints { make in
make.centerX.equalTo(contentView)
make.top.equalTo(instructionsLabel.snp.bottom).offset(RemoteTabsPanelUX.EmptyStateTopPaddingInBetweenItems)
make.height.equalTo(RemoteTabsPanelUX.EmptyStateSignInButtonHeight)
make.width.equalTo(RemoteTabsPanelUX.EmptyStateSignInButtonWidth)
}
}
super.updateConstraints()
}
}
fileprivate class RemoteTabsTableViewController: UITableViewController {
weak var remoteTabsPanel: RemoteTabsPanel?
var profile: Profile!
var tableViewDelegate: RemoteTabsPanelDataSource? {
didSet {
tableView.dataSource = tableViewDelegate
tableView.delegate = tableViewDelegate
}
}
fileprivate lazy var longPressRecognizer: UILongPressGestureRecognizer = {
return UILongPressGestureRecognizer(target: self, action: #selector(RemoteTabsTableViewController.longPress(_:)))
}()
override func viewDidLoad() {
super.viewDidLoad()
tableView.addGestureRecognizer(longPressRecognizer)
tableView.register(TwoLineHeaderFooterView.self, forHeaderFooterViewReuseIdentifier: RemoteClientIdentifier)
tableView.register(TwoLineTableViewCell.self, forCellReuseIdentifier: RemoteTabIdentifier)
tableView.rowHeight = RemoteTabsPanelUX.RowHeight
tableView.separatorInset = UIEdgeInsets.zero
tableView.delegate = nil
tableView.dataSource = nil
refreshControl = UIRefreshControl()
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
refreshControl?.addTarget(self, action: #selector(RemoteTabsTableViewController.refreshTabs), for: .valueChanged)
refreshTabs()
}
override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
refreshControl?.removeTarget(self, action: #selector(RemoteTabsTableViewController.refreshTabs), for: .valueChanged)
}
fileprivate func startRefreshing() {
if let refreshControl = self.refreshControl {
let height = -refreshControl.bounds.size.height
tableView.setContentOffset(CGPoint(x: 0, y: height), animated: true)
refreshControl.beginRefreshing()
}
}
func endRefreshing() {
if self.refreshControl?.isRefreshing ?? false {
self.refreshControl?.endRefreshing()
}
self.tableView.isScrollEnabled = true
self.tableView.reloadData()
}
func updateDelegateClientAndTabData(_ clientAndTabs: [ClientAndTabs]) {
guard let remoteTabsPanel = remoteTabsPanel else { return }
if clientAndTabs.count == 0 {
self.tableViewDelegate = RemoteTabsPanelErrorDataSource(homePanel: remoteTabsPanel, error: .noClients)
} else {
let nonEmptyClientAndTabs = clientAndTabs.filter { $0.tabs.count > 0 }
if nonEmptyClientAndTabs.count == 0 {
self.tableViewDelegate = RemoteTabsPanelErrorDataSource(homePanel: remoteTabsPanel, error: .noTabs)
} else {
self.tableViewDelegate = RemoteTabsPanelClientAndTabsDataSource(homePanel: remoteTabsPanel, clientAndTabs: nonEmptyClientAndTabs)
tableView.allowsSelection = true
}
}
}
@objc fileprivate func refreshTabs() {
guard let remoteTabsPanel = remoteTabsPanel else { return }
assert(Thread.isMainThread)
tableView.isScrollEnabled = false
tableView.allowsSelection = false
tableView.tableFooterView = UIView(frame: CGRect.zero)
// Short circuit if the user is not logged in
if !profile.hasSyncableAccount() {
self.tableViewDelegate = RemoteTabsPanelErrorDataSource(homePanel: remoteTabsPanel, error: .notLoggedIn)
self.endRefreshing()
return
}
self.profile.getCachedClientsAndTabs().uponQueue(DispatchQueue.main) { result in
if let clientAndTabs = result.successValue {
self.updateDelegateClientAndTabData(clientAndTabs)
}
// Fetch the tabs from the cloud if it has been more than 5 seconds since the last sync.
let lastSyncTime = self.profile.prefs.timestampForKey(PrefsKeys.KeyLastRemoteTabSyncTime) ?? 0
if Date.now() > lastSyncTime && Date.now() - lastSyncTime > OneSecondInMilliseconds * 5 {
self.startRefreshing()
self.profile.getClientsAndTabs().uponQueue(DispatchQueue.main) { result in
// We set the last sync time to now, regardless of whether the sync was successful, to avoid trying to sync over
// and over again in cases whether the client is unable to sync (e.g. when there is no network connectivity).
self.profile.prefs.setTimestamp(Date.now(), forKey: PrefsKeys.KeyLastRemoteTabSyncTime)
if let clientAndTabs = result.successValue {
self.updateDelegateClientAndTabData(clientAndTabs)
}
self.endRefreshing()
}
} else {
// If we failed before and didn't sync, show the failure delegate
if let _ = result.failureValue {
self.tableViewDelegate = RemoteTabsPanelErrorDataSource(homePanel: remoteTabsPanel, error: .failedToSync)
}
self.endRefreshing()
}
}
}
@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)
}
}
extension RemoteTabsTableViewController: 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 tab = (tableViewDelegate as? RemoteTabsPanelClientAndTabsDataSource)?.tabAtIndexPath(indexPath) else { return nil }
return Site(url: String(describing: tab.URL), title: tab.title)
}
func getContextMenuActions(for site: Site, with indexPath: IndexPath) -> [PhotonActionSheetItem]? {
return getDefaultContextMenuActions(for: site, homePanelDelegate: remoteTabsPanel?.homePanelDelegate)
}
}