/* 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) } }