Dactyloidae iOS initial commit
1073
mobile/ios/Client/Frontend/Home/ActivityStreamPanel.swift
Normal file
558
mobile/ios/Client/Frontend/Home/ActivityStreamTopSitesCell.swift
Normal 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)
|
||||
}
|
||||
|
||||
}
|
||||
593
mobile/ios/Client/Frontend/Home/BookmarksPanel.swift
Normal 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 isn’t 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()
|
||||
}
|
||||
}
|
||||
574
mobile/ios/Client/Frontend/Home/HistoryPanel.swift
Normal 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
|
||||
}
|
||||
}
|
||||
23
mobile/ios/Client/Frontend/Home/Home.xcassets/AddToReadingListCircle.imageset/Contents.json
vendored
Normal 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"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"info" : {
|
||||
"version" : 1,
|
||||
"author" : "xcode"
|
||||
}
|
||||
}
|
||||
23
mobile/ios/Client/Frontend/Home/Home.xcassets/ReaderModeCircle.imageset/Contents.json
vendored
Normal 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"
|
||||
}
|
||||
}
|
||||
BIN
mobile/ios/Client/Frontend/Home/Home.xcassets/ReaderModeCircle.imageset/reader view circle.png
vendored
Normal file
|
After Width: | Height: | Size: 1 KiB |
BIN
mobile/ios/Client/Frontend/Home/Home.xcassets/ReaderModeCircle.imageset/reader view circle@2x.png
vendored
Normal file
|
After Width: | Height: | Size: 2 KiB |
BIN
mobile/ios/Client/Frontend/Home/Home.xcassets/ReaderModeCircle.imageset/reader view circle@3x.png
vendored
Normal file
|
After Width: | Height: | Size: 3.5 KiB |
23
mobile/ios/Client/Frontend/Home/Home.xcassets/clear.imageset/Contents.json
vendored
Normal 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"
|
||||
}
|
||||
}
|
||||
BIN
mobile/ios/Client/Frontend/Home/Home.xcassets/clear.imageset/clear.png
vendored
Normal file
|
After Width: | Height: | Size: 609 B |
BIN
mobile/ios/Client/Frontend/Home/Home.xcassets/clear.imageset/clear@2x.png
vendored
Normal file
|
After Width: | Height: | Size: 910 B |
BIN
mobile/ios/Client/Frontend/Home/Home.xcassets/clear.imageset/clear@3x.png
vendored
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
mobile/ios/Client/Frontend/Home/Home.xcassets/emptyBookmarks.imageset/BookmarksEmptyPanel.png
vendored
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
mobile/ios/Client/Frontend/Home/Home.xcassets/emptyBookmarks.imageset/BookmarksEmptyPanel@2x.png
vendored
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
mobile/ios/Client/Frontend/Home/Home.xcassets/emptyBookmarks.imageset/BookmarksEmptyPanel@3x.png
vendored
Normal file
|
After Width: | Height: | Size: 4.1 KiB |
23
mobile/ios/Client/Frontend/Home/Home.xcassets/emptyBookmarks.imageset/Contents.json
vendored
Normal 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"
|
||||
}
|
||||
}
|
||||
23
mobile/ios/Client/Frontend/Home/Home.xcassets/emptyHistory.imageset/Contents.json
vendored
Normal 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"
|
||||
}
|
||||
}
|
||||
BIN
mobile/ios/Client/Frontend/Home/Home.xcassets/emptyHistory.imageset/historyEmptyPanel.png
vendored
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
mobile/ios/Client/Frontend/Home/Home.xcassets/emptyHistory.imageset/historyEmptyPanel@2x.png
vendored
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
mobile/ios/Client/Frontend/Home/Home.xcassets/emptyHistory.imageset/historyEmptyPanel@3x.png
vendored
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
23
mobile/ios/Client/Frontend/Home/Home.xcassets/emptySync.imageset/Contents.json
vendored
Normal 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"
|
||||
}
|
||||
}
|
||||
BIN
mobile/ios/Client/Frontend/Home/Home.xcassets/emptySync.imageset/sync-devices.png
vendored
Normal file
|
After Width: | Height: | Size: 4 KiB |
BIN
mobile/ios/Client/Frontend/Home/Home.xcassets/emptySync.imageset/sync-devices@2x.png
vendored
Normal file
|
After Width: | Height: | Size: 8.6 KiB |
BIN
mobile/ios/Client/Frontend/Home/Home.xcassets/emptySync.imageset/sync-devices@3x.png
vendored
Normal file
|
After Width: | Height: | Size: 14 KiB |
23
mobile/ios/Client/Frontend/Home/Home.xcassets/emptyTopSites.imageset/Contents.json
vendored
Normal 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"
|
||||
}
|
||||
}
|
||||
BIN
mobile/ios/Client/Frontend/Home/Home.xcassets/emptyTopSites.imageset/topSitesEmptyPanel.png
vendored
Normal file
|
After Width: | Height: | Size: 924 B |
BIN
mobile/ios/Client/Frontend/Home/Home.xcassets/emptyTopSites.imageset/topSitesEmptyPanel@2x.png
vendored
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
mobile/ios/Client/Frontend/Home/Home.xcassets/emptyTopSites.imageset/topSitesEmptyPanel@3x.png
vendored
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
23
mobile/ios/Client/Frontend/Home/Home.xcassets/panelIconBookmarks.imageset/Contents.json
vendored
Normal 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"
|
||||
}
|
||||
}
|
||||
BIN
mobile/ios/Client/Frontend/Home/Home.xcassets/panelIconBookmarks.imageset/bookmark-Outline.png
vendored
Normal file
|
After Width: | Height: | Size: 334 B |
BIN
mobile/ios/Client/Frontend/Home/Home.xcassets/panelIconBookmarks.imageset/bookmark-Outline@2x.png
vendored
Normal file
|
After Width: | Height: | Size: 486 B |
BIN
mobile/ios/Client/Frontend/Home/Home.xcassets/panelIconBookmarks.imageset/bookmark-Outline@3x.png
vendored
Normal file
|
After Width: | Height: | Size: 664 B |
23
mobile/ios/Client/Frontend/Home/Home.xcassets/panelIconHistory.imageset/Contents.json
vendored
Normal 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"
|
||||
}
|
||||
}
|
||||
BIN
mobile/ios/Client/Frontend/Home/Home.xcassets/panelIconHistory.imageset/history.png
vendored
Normal file
|
After Width: | Height: | Size: 266 B |
BIN
mobile/ios/Client/Frontend/Home/Home.xcassets/panelIconHistory.imageset/history@2x.png
vendored
Normal file
|
After Width: | Height: | Size: 438 B |
BIN
mobile/ios/Client/Frontend/Home/Home.xcassets/panelIconHistory.imageset/history@3x.png
vendored
Normal file
|
After Width: | Height: | Size: 602 B |
23
mobile/ios/Client/Frontend/Home/Home.xcassets/panelIconReadingList.imageset/Contents.json
vendored
Normal 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"
|
||||
}
|
||||
}
|
||||
BIN
mobile/ios/Client/Frontend/Home/Home.xcassets/panelIconReadingList.imageset/reading-list.png
vendored
Normal file
|
After Width: | Height: | Size: 188 B |
BIN
mobile/ios/Client/Frontend/Home/Home.xcassets/panelIconReadingList.imageset/reading-list@2x.png
vendored
Normal file
|
After Width: | Height: | Size: 267 B |
BIN
mobile/ios/Client/Frontend/Home/Home.xcassets/panelIconReadingList.imageset/reading-list@3x.png
vendored
Normal file
|
After Width: | Height: | Size: 341 B |
23
mobile/ios/Client/Frontend/Home/Home.xcassets/panelIconSyncedTabs.imageset/Contents.json
vendored
Normal 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"
|
||||
}
|
||||
}
|
||||
BIN
mobile/ios/Client/Frontend/Home/Home.xcassets/panelIconSyncedTabs.imageset/Synced Panel.png
vendored
Normal file
|
After Width: | Height: | Size: 798 B |
BIN
mobile/ios/Client/Frontend/Home/Home.xcassets/panelIconSyncedTabs.imageset/Synced Panel@2x.png
vendored
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
mobile/ios/Client/Frontend/Home/Home.xcassets/panelIconSyncedTabs.imageset/Synced Panel@3x.png
vendored
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
23
mobile/ios/Client/Frontend/Home/Home.xcassets/panelIconTopSites.imageset/Contents.json
vendored
Normal 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"
|
||||
}
|
||||
}
|
||||
BIN
mobile/ios/Client/Frontend/Home/Home.xcassets/panelIconTopSites.imageset/topsites.png
vendored
Normal file
|
After Width: | Height: | Size: 191 B |
BIN
mobile/ios/Client/Frontend/Home/Home.xcassets/panelIconTopSites.imageset/topsites@2x.png
vendored
Normal file
|
After Width: | Height: | Size: 243 B |
BIN
mobile/ios/Client/Frontend/Home/Home.xcassets/panelIconTopSites.imageset/topsites@3x.png
vendored
Normal file
|
After Width: | Height: | Size: 324 B |
364
mobile/ios/Client/Frontend/Home/HomePanelViewController.swift
Normal 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]
|
||||
}
|
||||
}
|
||||
69
mobile/ios/Client/Frontend/Home/HomePanels.swift
Normal 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"),
|
||||
]
|
||||
}
|
||||
97
mobile/ios/Client/Frontend/Home/PanelDataObservers.swift
Normal 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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
456
mobile/ios/Client/Frontend/Home/ReaderPanel.swift
Normal 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
|
||||
}
|
||||
}
|
||||
178
mobile/ios/Client/Frontend/Home/RecentlyClosedTabsPanel.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
610
mobile/ios/Client/Frontend/Home/RemoteTabsPanel.swift
Normal 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 don’t 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)
|
||||
}
|
||||
}
|
||||