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

1073 lines
46 KiB
Swift

/* 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 Shared
import UIKit
import Deferred
import Storage
import SDWebImage
import XCGLogger
import SyncTelemetry
import SnapKit
private let log = Logger.browserLogger
private let DefaultSuggestedSitesKey = "topSites.deletedSuggestedSites"
// MARK: - Lifecycle
struct ASPanelUX {
static let backgroundColor = UIConstants.AppBackgroundColor
static let rowSpacing: CGFloat = UIDevice.current.userInterfaceIdiom == .pad ? 30 : 20
static let highlightCellHeight: CGFloat = UIDevice.current.userInterfaceIdiom == .pad ? 250 : 200
static let sectionInsetsForSizeClass = UXSizeClasses(compact: 0, regular: 101, other: 14)
static let numberOfItemsPerRowForSizeClassIpad = UXSizeClasses(compact: 3, regular: 4, other: 2)
static let SectionInsetsForIpad: CGFloat = 101
static let SectionInsetsForIphone: CGFloat = 14
static let MinimumInsets: CGFloat = 14
static let BookmarkHighlights = 2
}
/*
Size classes are the way Apple requires us to specify our UI.
Split view on iPad can make a landscape app appear with the demensions of an iPhone app
Use UXSizeClasses to specify things like offsets/itemsizes with respect to size classes
For a primer on size classes https://useyourloaf.com/blog/size-classes/
*/
struct UXSizeClasses {
var compact: CGFloat
var regular: CGFloat
var unspecified: CGFloat
init(compact: CGFloat, regular: CGFloat, other: CGFloat) {
self.compact = compact
self.regular = regular
self.unspecified = other
}
subscript(sizeClass: UIUserInterfaceSizeClass) -> CGFloat {
switch sizeClass {
case .compact:
return self.compact
case .regular:
return self.regular
case .unspecified:
return self.unspecified
}
}
}
class ActivityStreamPanel: UICollectionViewController, HomePanel {
weak var homePanelDelegate: HomePanelDelegate?
fileprivate let profile: Profile
fileprivate let telemetry: ActivityStreamTracker
fileprivate let pocketAPI = Pocket()
fileprivate let flowLayout: UICollectionViewFlowLayout = UICollectionViewFlowLayout()
fileprivate let topSitesManager = ASHorizontalScrollCellManager()
fileprivate var showHighlightIntro = false
fileprivate var sessionStart: Timestamp?
fileprivate lazy var longPressRecognizer: UILongPressGestureRecognizer = {
return UILongPressGestureRecognizer(target: self, action: #selector(ActivityStreamPanel.longPress(_:)))
}()
// Not used for displaying. Only used for calculating layout.
lazy var topSiteCell: ASHorizontalScrollCell = {
let customCell = ASHorizontalScrollCell(frame: CGRect(x: 0, y: 0, width: self.view.frame.size.width, height: 0))
customCell.delegate = self.topSitesManager
return customCell
}()
var highlights: [Site] = []
var pocketStories: [PocketStory] = []
init(profile: Profile, telemetry: ActivityStreamTracker? = nil) {
self.profile = profile
self.telemetry = telemetry ?? ActivityStreamTracker(eventsTracker: PingCentre.clientForTopic(.ActivityStreamEvents, clientID: profile.clientID), sessionsTracker: PingCentre.clientForTopic(.ActivityStreamSessions, clientID: profile.clientID))
super.init(collectionViewLayout: flowLayout)
self.collectionView?.delegate = self
self.collectionView?.dataSource = self
collectionView?.addGestureRecognizer(longPressRecognizer)
NotificationCenter.default.addObserver(self,
selector: #selector(didChangeFontSize(notification:)),
name: NotificationDynamicFontChanged,
object: nil)
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
Section.allValues.forEach { self.collectionView?.register(Section($0.rawValue).cellType, forCellWithReuseIdentifier: Section($0.rawValue).cellIdentifier) }
self.collectionView?.register(ASHeaderView.self, forSupplementaryViewOfKind: UICollectionElementKindSectionHeader, withReuseIdentifier: "Header")
self.collectionView?.register(ASFooterView.self, forSupplementaryViewOfKind: UICollectionElementKindSectionFooter, withReuseIdentifier: "Footer")
collectionView?.backgroundColor = ASPanelUX.backgroundColor
collectionView?.keyboardDismissMode = .onDrag
self.profile.panelDataObservers.activityStream.delegate = self
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
sessionStart = Date.now()
reloadAll()
}
override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
telemetry.reportSessionStop(Date.now() - (sessionStart ?? 0))
sessionStart = nil
}
override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
super.viewWillTransition(to: size, with: coordinator)
coordinator.animate(alongsideTransition: {context in
//The AS context menu does not behave correctly. Dismiss it when rotating.
if let _ = self.presentedViewController as? PhotonActionSheet {
self.presentedViewController?.dismiss(animated: true, completion: nil)
}
self.collectionViewLayout.invalidateLayout()
self.collectionView?.reloadData()
}, completion: nil)
}
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
super.traitCollectionDidChange(previousTraitCollection)
self.topSitesManager.currentTraits = self.traitCollection
}
func didChangeFontSize(notification: Notification) {
// Don't need to invalidate the data for a font change. Just reload the UI.
reloadAll()
}
}
// MARK: - Section management
extension ActivityStreamPanel {
enum Section: Int {
case topSites
case pocket
case highlights
case highlightIntro
static let count = 4
static let allValues = [topSites, pocket, highlights, highlightIntro]
var title: String? {
switch self {
case .highlights: return Strings.ASHighlightsTitle
case .pocket: return Strings.ASPocketTitle
case .topSites: return nil
case .highlightIntro: return nil
}
}
var headerHeight: CGSize {
switch self {
case .highlights, .pocket: return CGSize(width: 50, height: 40)
case .topSites: return CGSize(width: 0, height: 0)
case .highlightIntro: return CGSize(width: 50, height: 2)
}
}
var footerHeight: CGSize {
switch self {
case .highlights, .highlightIntro, .pocket: return CGSize.zero
case .topSites: return CGSize(width: 50, height: 5)
}
}
func cellHeight(_ traits: UITraitCollection, width: CGFloat) -> CGFloat {
switch self {
case .highlights, .pocket: return ASPanelUX.highlightCellHeight
case .topSites: return 0 //calculated dynamically
case .highlightIntro: return 200
}
}
/*
There are edge cases to handle when calculating section insets
- An iPhone 7+ is considered regular width when in landscape
- An iPad in 66% split view is still considered regular width
*/
func sectionInsets(_ traits: UITraitCollection, frameWidth: CGFloat) -> CGFloat {
var currentTraits = traits
if (traits.horizontalSizeClass == .regular && UIScreen.main.bounds.size.width != frameWidth) || UIDevice.current.userInterfaceIdiom == .phone {
currentTraits = UITraitCollection(horizontalSizeClass: .compact)
}
switch self {
case .highlights, .pocket:
var insets = ASPanelUX.sectionInsetsForSizeClass[currentTraits.horizontalSizeClass]
insets = insets + ASPanelUX.MinimumInsets
return insets
case .topSites:
return ASPanelUX.sectionInsetsForSizeClass[currentTraits.horizontalSizeClass]
case .highlightIntro:
return ASPanelUX.sectionInsetsForSizeClass[currentTraits.horizontalSizeClass]
}
}
func numberOfItemsForRow(_ traits: UITraitCollection) -> CGFloat {
switch self {
case .highlights, .pocket:
var numItems: CGFloat = ASPanelUX.numberOfItemsPerRowForSizeClassIpad[traits.horizontalSizeClass]
if UIInterfaceOrientationIsPortrait(UIApplication.shared.statusBarOrientation) {
numItems = numItems - 1
}
if traits.horizontalSizeClass == .compact && UIInterfaceOrientationIsLandscape(UIApplication.shared.statusBarOrientation) {
numItems = numItems - 1
}
return numItems
case .topSites, .highlightIntro:
return 1
}
}
func cellSize(for traits: UITraitCollection, frameWidth: CGFloat) -> CGSize {
let height = cellHeight(traits, width: frameWidth)
let inset = sectionInsets(traits, frameWidth: frameWidth) * 2
switch self {
case .highlights, .pocket:
let numItems = numberOfItemsForRow(traits)
return CGSize(width: floor(((frameWidth - inset) - (ASPanelUX.MinimumInsets * (numItems - 1))) / numItems), height: height)
case .topSites:
return CGSize(width: frameWidth - inset, height: height)
case .highlightIntro:
return CGSize(width: frameWidth - inset - (ASPanelUX.MinimumInsets * 2), height: height)
}
}
var headerView: UIView? {
switch self {
case .highlights, .highlightIntro, .pocket:
let view = ASHeaderView()
view.title = title
return view
case .topSites:
return nil
}
}
var cellIdentifier: String {
switch self {
case .topSites: return "TopSiteCell"
case .highlights: return "HistoryCell"
case .pocket: return "PocketCell"
case .highlightIntro: return "HighlightIntroCell"
}
}
var cellType: UICollectionViewCell.Type {
switch self {
case .topSites: return ASHorizontalScrollCell.self
case .highlights, .pocket: return ActivityStreamHighlightCell.self
case .highlightIntro: return HighlightIntroCell.self
}
}
init(at indexPath: IndexPath) {
self.init(rawValue: indexPath.section)!
}
init(_ section: Int) {
self.init(rawValue: section)!
}
}
}
// MARK: - Tableview Delegate
extension ActivityStreamPanel: UICollectionViewDelegateFlowLayout {
override func collectionView(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath) -> UICollectionReusableView {
switch kind {
case UICollectionElementKindSectionHeader:
let view = collectionView.dequeueReusableSupplementaryView(ofKind: UICollectionElementKindSectionHeader, withReuseIdentifier: "Header", for: indexPath) as! ASHeaderView
let title = Section(indexPath.section).title
switch Section(indexPath.section) {
case .highlights, .highlightIntro:
view.title = title
return view
case .pocket:
view.title = title
view.moreButton.isHidden = false
view.moreButton.addTarget(self, action: #selector(ActivityStreamPanel.showMorePocketStories), for: .touchUpInside)
return view
case .topSites:
return UICollectionReusableView()
}
case UICollectionElementKindSectionFooter:
let view = collectionView.dequeueReusableSupplementaryView(ofKind: UICollectionElementKindSectionFooter, withReuseIdentifier: "Footer", for: indexPath) as! ASFooterView
switch Section(indexPath.section) {
case .highlights, .highlightIntro:
return UICollectionReusableView()
case .topSites, .pocket:
return view
}
default:
return UICollectionReusableView()
}
}
override func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
self.longPressRecognizer.isEnabled = false
selectItemAtIndex(indexPath.item, inSection: Section(indexPath.section))
}
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
let cellSize = Section(indexPath.section).cellSize(for: self.traitCollection, frameWidth: self.view.frame.width)
switch Section(indexPath.section) {
case .highlights:
if highlights.isEmpty {
return CGSize.zero
}
return cellSize
case .topSites:
// Create a temporary cell so we can calculate the height.
let layout = topSiteCell.collectionView.collectionViewLayout as! HorizontalFlowLayout
let estimatedLayout = layout.calculateLayout(for: CGSize(width: cellSize.width, height: 0))
return CGSize(width: cellSize.width, height: estimatedLayout.size.height)
case .highlightIntro, .pocket:
return cellSize
}
}
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, referenceSizeForHeaderInSection section: Int) -> CGSize {
switch Section(section) {
case .highlights:
return highlights.isEmpty ? CGSize.zero : CGSize(width: self.view.frame.size.width, height: Section(section).headerHeight.height)
case .highlightIntro:
return !highlights.isEmpty ? CGSize.zero : CGSize(width: self.view.frame.size.width, height: Section(section).headerHeight.height)
case .pocket:
return pocketStories.isEmpty ? CGSize.zero : Section(section).headerHeight
case .topSites:
return Section(section).headerHeight
}
}
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, referenceSizeForFooterInSection section: Int) -> CGSize {
switch Section(section) {
case .highlights, .highlightIntro, .pocket:
return CGSize.zero
case .topSites:
return Section(section).footerHeight
}
}
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, minimumInteritemSpacingForSectionAt section: Int) -> CGFloat {
return 0
}
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, minimumLineSpacingForSectionAt section: Int) -> CGFloat {
return ASPanelUX.rowSpacing
}
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, insetForSectionAt section: Int) -> UIEdgeInsets {
let insets = Section(section).sectionInsets(self.traitCollection, frameWidth: self.view.frame.width)
return UIEdgeInsets(top: 0, left: insets, bottom: 0, right: insets)
}
fileprivate func showSiteWithURLHandler(_ url: URL) {
let visitType = VisitType.bookmark
homePanelDelegate?.homePanel(self, didSelectURL: url, visitType: visitType)
}
}
// MARK: - Tableview Data Source
extension ActivityStreamPanel {
override func numberOfSections(in collectionView: UICollectionView) -> Int {
return 4
}
override func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
var numItems: CGFloat = ASPanelUX.numberOfItemsPerRowForSizeClassIpad[self.traitCollection.horizontalSizeClass]
if UIInterfaceOrientationIsPortrait(UIApplication.shared.statusBarOrientation) {
numItems = numItems - 1
}
if self.traitCollection.horizontalSizeClass == .compact && UIInterfaceOrientationIsLandscape(UIApplication.shared.statusBarOrientation) {
numItems = numItems - 1
}
switch Section(section) {
case .topSites:
return topSitesManager.content.isEmpty ? 0 : 1
case .highlights:
return self.highlights.count
case .pocket:
return pocketStories.isEmpty ? 0 : Int(numItems)
case .highlightIntro:
return self.highlights.isEmpty && showHighlightIntro && isHighlightsEnabled() ? 1 : 0
}
}
override func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let identifier = Section(indexPath.section).cellIdentifier
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: identifier, for: indexPath)
switch Section(indexPath.section) {
case .topSites:
return configureTopSitesCell(cell, forIndexPath: indexPath)
case .highlights:
return configureHistoryItemCell(cell, forIndexPath: indexPath)
case .pocket:
return configurePocketItemCell(cell, forIndexPath: indexPath)
case .highlightIntro:
return configureHighlightIntroCell(cell, forIndexPath: indexPath)
}
}
//should all be collectionview
func configureTopSitesCell(_ cell: UICollectionViewCell, forIndexPath indexPath: IndexPath) -> UICollectionViewCell {
let topSiteCell = cell as! ASHorizontalScrollCell
topSiteCell.delegate = self.topSitesManager
topSiteCell.setNeedsLayout()
topSiteCell.collectionView.reloadData()
return cell
}
func configureHistoryItemCell(_ cell: UICollectionViewCell, forIndexPath indexPath: IndexPath) -> UICollectionViewCell {
let site = highlights[indexPath.row]
let simpleHighlightCell = cell as! ActivityStreamHighlightCell
simpleHighlightCell.configureWithSite(site)
return simpleHighlightCell
}
func configurePocketItemCell(_ cell: UICollectionViewCell, forIndexPath indexPath: IndexPath) -> UICollectionViewCell {
let pocketStory = pocketStories[indexPath.row]
let pocketItemCell = cell as! ActivityStreamHighlightCell
pocketItemCell.configureWithPocketStory(pocketStory)
return pocketItemCell
}
func configureHighlightIntroCell(_ cell: UICollectionViewCell, forIndexPath indexPath: IndexPath) -> UICollectionViewCell {
let introCell = cell as! HighlightIntroCell
//The cell is configured on creation. No need to configure. But leave this here in case we need it.
return introCell
}
}
// MARK: - Data Management
extension ActivityStreamPanel: DataObserverDelegate {
fileprivate func reportMissingData(sites: [Site], source: ASPingSource) {
let missingImagePings: [[String: Any]] = sites.flatMap { site in
if site.metadata?.mediaURL == nil {
return self.telemetry.pingFor(badState: .MissingMetadataImage, source: source)
}
return nil
}
let missingFaviconPings: [[String: Any]] = sites.flatMap { site in
if site.icon == nil {
return self.telemetry.pingFor(badState: .MissingFavicon, source: source)
}
return nil
}
let badPings = missingImagePings + missingFaviconPings
self.telemetry.eventsTracker.sendBatch(badPings, validate: true)
}
// Reloads both highlights and top sites data from their respective caches. Does not invalidate the cache.
// See ActivityStreamDataObserver for invalidation logic.
func reloadAll() {
// If the pocket stories are not availible for the Locale the PocketAPI will return nil
// So it is okay if the default here is true
self.getPocketSites().uponQueue(.main) { _ in
if !self.pocketStories.isEmpty {
self.collectionView?.reloadData()
}
}
accumulate([self.getHighlights, self.getTopSites]).uponQueue(.main) { _ in
// If there is no pending cache update and highlights are empty. Show the onboarding screen
self.showHighlightIntro = self.highlights.isEmpty
self.collectionView?.reloadData()
// Refresh the AS data in the background so we'll have fresh data next time we show.
self.profile.panelDataObservers.activityStream.refreshIfNeeded(forceHighlights: false, forceTopSites: false)
}
}
func getBookmarksForHighlights() -> Deferred<Maybe<Cursor<Site>>> {
let count = ASPanelUX.BookmarkHighlights // Fetch 2 bookmarks
return self.profile.recommendations.getRecentBookmarks(count)
}
// Used to check if the entire section is turned off
// when it is we shouldnt show the emtpy state
func isHighlightsEnabled() -> Bool {
let bookmarks = profile.prefs.boolForKey(PrefsKeys.ASBookmarkHighlightsVisible) ?? false
let history = profile.prefs.boolForKey(PrefsKeys.ASRecentHighlightsVisible) ?? false
return history && bookmarks
}
func getHighlights() -> Success {
var queries: [() -> Deferred<Maybe<Cursor<Site>>>] = []
if profile.prefs.boolForKey(PrefsKeys.ASBookmarkHighlightsVisible) ?? true {
queries.append(getBookmarksForHighlights)
}
if profile.prefs.boolForKey(PrefsKeys.ASRecentHighlightsVisible) ?? true {
queries.append(self.profile.recommendations.getHighlights)
}
guard !queries.isEmpty else {
self.highlights = []
return succeed()
}
return accumulate(queries).bindQueue(.main) { result in
guard let resultArr = result.successValue else {
return succeed()
}
let sites = resultArr.reduce([]) { $0 + $1.asArray() }
// Scan through the fetched highlights and report on anything that might be missing.
self.reportMissingData(sites: sites, source: .Highlights)
self.highlights = sites
return succeed()
}
}
func getPocketSites() -> Success {
let showPocket = (profile.prefs.boolForKey(PrefsKeys.ASPocketStoriesVisible) ?? Pocket.IslocaleSupported(Locale.current.identifier)) && AppConstants.MOZ_POCKET_STORIES
guard showPocket else {
self.pocketStories = []
return succeed()
}
return pocketAPI.globalFeed(items: 4).bindQueue(.main) { pStory in
self.pocketStories = pStory
return succeed()
}
}
@objc func showMorePocketStories() {
showSiteWithURLHandler(Pocket.MoreStoriesURL)
}
func getTopSites() -> Success {
return self.profile.history.getTopSitesWithLimit(16).both(self.profile.history.getPinnedTopSites()).bindQueue(.main) { (topsites, pinnedSites) in
guard let mySites = topsites.successValue?.asArray(), let pinned = pinnedSites.successValue?.asArray() else {
return succeed()
}
// How sites are merged together. We compare against the urls second level domain. example m.youtube.com is compared against `youtube`
let unionOnURL = { (site: Site) -> String in
return URL(string: site.url)?.hostSLD ?? ""
}
// Fetch the default sites
let defaultSites = self.defaultTopSites()
// create PinnedSite objects. used by the view layer to tell topsites apart
let pinnedSites: [Site] = pinned.map({ PinnedSite(site: $0) })
// Merge default topsites with a user's topsites.
let mergedSites = mySites.union(defaultSites, f: unionOnURL)
// Merge pinnedSites with sites from the previous step
let allSites = pinnedSites.union(mergedSites, f: unionOnURL)
// Favour topsites from defaultSites as they have better favicons. But keep PinnedSites
let newSites = allSites.map { site -> Site in
if let _ = site as? PinnedSite {
return site
}
let domain = URL(string: site.url)?.hostSLD
return defaultSites.find { $0.title.lowercased() == domain } ?? site
}
// Don't report bad states for default sites we provide
self.reportMissingData(sites: mySites, source: .TopSites)
self.topSitesManager.currentTraits = self.view.traitCollection
if newSites.count > Int(ActivityStreamTopSiteCacheSize) {
self.topSitesManager.content = Array(newSites[0..<Int(ActivityStreamTopSiteCacheSize)])
} else {
self.topSitesManager.content = newSites
}
self.topSitesManager.urlPressedHandler = { [unowned self] url, indexPath in
self.longPressRecognizer.isEnabled = false
self.telemetry.reportEvent(.Click, source: .TopSites, position: indexPath.item)
self.showSiteWithURLHandler(url as URL)
}
return succeed()
}
}
// Invoked by the ActivityStreamDataObserver when highlights/top sites invalidation is complete.
func didInvalidateDataSources(refresh forced: Bool, highlightsRefreshed: Bool, topSitesRefreshed: Bool) {
// Do not reload panel unless we're currently showing the highlight intro or if we
// force-reloaded the highlights or top sites. This should prevent reloading the
// panel after we've invalidated in the background on the first load.
if showHighlightIntro || forced {
reloadAll()
}
}
func hideURLFromTopSites(_ site: Site) {
guard let host = site.tileURL.normalizedHost else {
return
}
let url = site.tileURL.absoluteString
// if the default top sites contains the siteurl. also wipe it from default suggested sites.
if defaultTopSites().filter({$0.url == url}).isEmpty == false {
deleteTileForSuggestedSite(url)
}
profile.history.removeHostFromTopSites(host).uponQueue(.main) { result in
guard result.isSuccess else { return }
self.profile.panelDataObservers.activityStream.refreshIfNeeded(forceHighlights: false, forceTopSites: true)
}
}
func pinTopSite(_ site: Site) {
profile.history.addPinnedTopSite(site).uponQueue(.main) { result in
guard result.isSuccess else { return }
self.profile.panelDataObservers.activityStream.refreshIfNeeded(forceHighlights: false, forceTopSites: true)
}
}
func removePinTopSite(_ site: Site) {
profile.history.removeFromPinnedTopSites(site).uponQueue(.main) { result in
guard result.isSuccess else { return }
self.profile.panelDataObservers.activityStream.refreshIfNeeded(forceHighlights: false, forceTopSites: true)
}
}
func hideFromHighlights(_ site: Site) {
profile.recommendations.removeHighlightForURL(site.url).uponQueue(.main) { result in
guard result.isSuccess else { return }
self.profile.panelDataObservers.activityStream.refreshIfNeeded(forceHighlights: true, forceTopSites: false)
}
}
fileprivate func deleteTileForSuggestedSite(_ siteURL: String) {
var deletedSuggestedSites = profile.prefs.arrayForKey(DefaultSuggestedSitesKey) as? [String] ?? []
deletedSuggestedSites.append(siteURL)
profile.prefs.setObject(deletedSuggestedSites, forKey: DefaultSuggestedSitesKey)
}
func defaultTopSites() -> [Site] {
let suggested = SuggestedSites.asArray()
let deleted = profile.prefs.arrayForKey(DefaultSuggestedSitesKey) as? [String] ?? []
return suggested.filter({deleted.index(of: $0.url) == .none})
}
@objc fileprivate func longPress(_ longPressGestureRecognizer: UILongPressGestureRecognizer) {
guard longPressGestureRecognizer.state == UIGestureRecognizerState.began else { return }
let point = longPressGestureRecognizer.location(in: self.collectionView)
guard let indexPath = self.collectionView?.indexPathForItem(at: point) else { return }
switch Section(indexPath.section) {
case .highlights, .pocket:
presentContextMenu(for: indexPath)
case .topSites:
let topSiteCell = self.collectionView?.cellForItem(at: indexPath) as! ASHorizontalScrollCell
let pointInTopSite = longPressGestureRecognizer.location(in: topSiteCell.collectionView)
guard let topSiteIndexPath = topSiteCell.collectionView.indexPathForItem(at: pointInTopSite) else { return }
presentContextMenu(for: topSiteIndexPath)
case .highlightIntro:
break
}
}
fileprivate func fetchBookmarkStatus(for site: Site, with indexPath: IndexPath, forSection section: Section, completionHandler: @escaping () -> Void) {
profile.bookmarks.modelFactory >>== {
$0.isBookmarked(site.url).uponQueue(.main) { result in
guard let isBookmarked = result.successValue else {
log.error("Error getting bookmark status: \(result.failureValue ??? "nil").")
return
}
site.setBookmarked(isBookmarked)
completionHandler()
}
}
}
func selectItemAtIndex(_ index: Int, inSection section: Section) {
let site: Site?
switch section {
case .highlights:
site = self.highlights[index]
telemetry.reportEvent(.Click, source: .Highlights, position: index)
case .pocket:
site = Site(url: pocketStories[index].url.absoluteString, title: pocketStories[index].title)
telemetry.reportEvent(.Click, source: .Pocket, position: index)
LeanPlumClient.shared.track(event: .openedPocketStory, withParameters: ["Source": "Activity Stream" as AnyObject])
case .topSites, .highlightIntro:
return
}
if let site = site {
showSiteWithURLHandler(URL(string: site.url)!)
}
}
}
extension ActivityStreamPanel: HomePanelContextMenu {
func presentContextMenu(for site: Site, with indexPath: IndexPath, completionHandler: @escaping () -> PhotonActionSheet?) {
fetchBookmarkStatus(for: site, with: indexPath, forSection: Section(indexPath.section)) {
guard let contextMenu = completionHandler() else { return }
self.present(contextMenu, animated: true, completion: nil)
}
}
func getSiteDetails(for indexPath: IndexPath) -> Site? {
let site: Site
switch Section(indexPath.section) {
case .highlights:
site = highlights[indexPath.row]
case .pocket:
site = Site(url: pocketStories[indexPath.row].dedupeURL.absoluteString, title: pocketStories[indexPath.row].title)
case .topSites:
site = topSitesManager.content[indexPath.item]
case .highlightIntro:
return nil
}
return site
}
func getContextMenuActions(for site: Site, with indexPath: IndexPath) -> [PhotonActionSheetItem]? {
guard let siteURL = URL(string: site.url) else { return nil }
let pingSource: ASPingSource
let index: Int
var sourceView: UIView?
switch Section(indexPath.section) {
case .topSites:
pingSource = .TopSites
index = indexPath.item
if let topSiteCell = self.collectionView?.cellForItem(at: IndexPath(row: 0, section: 0)) as? ASHorizontalScrollCell {
sourceView = topSiteCell.collectionView.cellForItem(at: indexPath)
}
case .highlights:
pingSource = .Highlights
index = indexPath.row
sourceView = self.collectionView?.cellForItem(at: indexPath)
case .pocket:
pingSource = .Pocket
index = indexPath.item
sourceView = self.collectionView?.cellForItem(at: indexPath)
case .highlightIntro:
return nil
}
let openInNewTabAction = PhotonActionSheetItem(title: Strings.OpenInNewTabContextMenuTitle, iconString: "quick_action_new_tab") { action in
self.homePanelDelegate?.homePanelDidRequestToOpenInNewTab(siteURL, isPrivate: false)
self.telemetry.reportEvent(.NewTab, source: pingSource, position: index)
let source = ["Source": "Activity Stream Long Press Context Menu" as AnyObject]
LeanPlumClient.shared.track(event: .openedNewTab, withParameters: source)
if Section(indexPath.section) == .pocket {
LeanPlumClient.shared.track(event: .openedPocketStory, withParameters: source)
}
}
let openInNewPrivateTabAction = PhotonActionSheetItem(title: Strings.OpenInNewPrivateTabContextMenuTitle, iconString: "quick_action_new_private_tab") { action in
self.homePanelDelegate?.homePanelDidRequestToOpenInNewTab(siteURL, isPrivate: true)
}
let bookmarkAction: PhotonActionSheetItem
if site.bookmarked ?? false {
bookmarkAction = PhotonActionSheetItem(title: Strings.RemoveBookmarkContextMenuTitle, iconString: "action_bookmark_remove", handler: { action in
self.profile.bookmarks.modelFactory >>== {
$0.removeByURL(siteURL.absoluteString).uponQueue(.main) {_ in
self.profile.panelDataObservers.activityStream.refreshIfNeeded(forceHighlights: true, forceTopSites: false)
}
site.setBookmarked(false)
}
self.telemetry.reportEvent(.RemoveBookmark, source: pingSource, position: index)
UnifiedTelemetry.recordEvent(category: .action, method: .delete, object: .bookmark, value: .activityStream)
})
} else {
bookmarkAction = PhotonActionSheetItem(title: Strings.BookmarkContextMenuTitle, iconString: "action_bookmark", handler: { action in
let shareItem = ShareItem(url: site.url, title: site.title, favicon: site.icon)
_ = self.profile.bookmarks.shareItem(shareItem)
var userData = [QuickActions.TabURLKey: shareItem.url]
if let title = shareItem.title {
userData[QuickActions.TabTitleKey] = title
}
QuickActions.sharedInstance.addDynamicApplicationShortcutItemOfType(.openLastBookmark,
withUserData: userData,
toApplication: UIApplication.shared)
site.setBookmarked(true)
self.profile.panelDataObservers.activityStream.refreshIfNeeded(forceHighlights: true, forceTopSites: true)
self.telemetry.reportEvent(.AddBookmark, source: pingSource, position: index)
LeanPlumClient.shared.track(event: .savedBookmark)
UnifiedTelemetry.recordEvent(category: .action, method: .add, object: .bookmark, value: .activityStream)
})
}
let deleteFromHistoryAction = PhotonActionSheetItem(title: Strings.DeleteFromHistoryContextMenuTitle, iconString: "action_delete", handler: { action in
self.telemetry.reportEvent(.Delete, source: pingSource, position: index)
self.profile.history.removeHistoryForURL(site.url).uponQueue(.main) { result in
guard result.isSuccess else { return }
self.profile.panelDataObservers.activityStream.refreshIfNeeded(forceHighlights: true, forceTopSites: true)
}
})
let shareAction = PhotonActionSheetItem(title: Strings.ShareContextMenuTitle, iconString: "action_share", handler: { action in
let helper = ShareExtensionHelper(url: siteURL, tab: nil)
let controller = helper.createActivityViewController { completed, activityType in
self.telemetry.reportEvent(.Share, source: pingSource, position: index, shareProvider: activityType)
}
if UI_USER_INTERFACE_IDIOM() == UIUserInterfaceIdiom.pad, let popoverController = controller.popoverPresentationController {
let cellRect = sourceView?.frame ?? CGRect.zero
let cellFrameInSuperview = self.collectionView?.convert(cellRect, to: self.collectionView) ?? CGRect.zero
popoverController.sourceView = sourceView
popoverController.sourceRect = CGRect(origin: CGPoint(x: cellFrameInSuperview.size.width/2, y: cellFrameInSuperview.height/2), size: .zero)
popoverController.permittedArrowDirections = [.up, .down, .left]
popoverController.delegate = self
}
self.present(controller, animated: true, completion: nil)
})
let removeTopSiteAction = PhotonActionSheetItem(title: Strings.RemoveContextMenuTitle, iconString: "action_remove", handler: { action in
self.telemetry.reportEvent(.Remove, source: pingSource, position: index)
self.hideURLFromTopSites(site)
})
let dismissHighlightAction = PhotonActionSheetItem(title: Strings.RemoveContextMenuTitle, iconString: "action_remove", handler: { action in
self.telemetry.reportEvent(.Dismiss, source: pingSource, position: index)
self.hideFromHighlights(site)
})
let pinTopSite = PhotonActionSheetItem(title: Strings.PinTopsiteActionTitle, iconString: "action_pin", handler: { action in
self.pinTopSite(site)
})
let removePinTopSite = PhotonActionSheetItem(title: Strings.RemovePinTopsiteActionTitle, iconString: "action_unpin", handler: { action in
self.removePinTopSite(site)
})
let topSiteActions: [PhotonActionSheetItem]
if let _ = site as? PinnedSite {
topSiteActions = [removePinTopSite]
} else {
topSiteActions = [pinTopSite, removeTopSiteAction]
}
var actions = [openInNewTabAction, openInNewPrivateTabAction, bookmarkAction, shareAction]
switch Section(indexPath.section) {
case .highlights: actions.append(contentsOf: [dismissHighlightAction, deleteFromHistoryAction])
case .pocket: break
case .topSites: actions.append(contentsOf: topSiteActions)
case .highlightIntro: break
}
return actions
}
}
extension ActivityStreamPanel: UIPopoverPresentationControllerDelegate {
// Dismiss the popover if the device is being rotated.
// This is used by the Share UIActivityViewController action sheet on iPad
func popoverPresentationController(_ popoverPresentationController: UIPopoverPresentationController, willRepositionPopoverTo rect: UnsafeMutablePointer<CGRect>, in view: AutoreleasingUnsafeMutablePointer<UIView>) {
popoverPresentationController.presentedViewController.dismiss(animated: false, completion: nil)
}
}
// MARK: Telemetry
enum ASPingEvent: String {
case Click = "CLICK"
case Delete = "DELETE"
case Dismiss = "DISMISS"
case Share = "SHARE"
case NewTab = "NEW_TAB"
case AddBookmark = "ADD_BOOKMARK"
case RemoveBookmark = "REMOVE_BOOKMARK"
case Remove = "REMOVE"
}
enum ASPingBadStateEvent: String {
case MissingMetadataImage = "MISSING_METADATA_IMAGE"
case MissingFavicon = "MISSING_FAVICON"
}
enum ASPingSource: String {
case Highlights = "HIGHLIGHTS"
case TopSites = "TOP_SITES"
case HighlightsIntro = "HIGHLIGHTS_INTRO"
case Pocket = "POCKET"
}
struct ActivityStreamTracker {
let eventsTracker: PingCentreClient
let sessionsTracker: PingCentreClient
private var baseASPing: [String: Any] {
return [
"app_version": AppInfo.appVersion,
"build": AppInfo.buildNumber,
"locale": Locale.current.identifier,
"release_channel": AppConstants.BuildChannel.rawValue
]
}
func pingFor(badState: ASPingBadStateEvent, source: ASPingSource) -> [String: Any] {
var eventPing: [String: Any] = [
"event": badState.rawValue,
"page": "NEW_TAB",
"source": source.rawValue,
]
eventPing.merge(with: baseASPing)
return eventPing
}
func reportEvent(_ event: ASPingEvent, source: ASPingSource, position: Int, shareProvider: String? = nil) {
var eventPing: [String: Any] = [
"event": event.rawValue,
"page": "NEW_TAB",
"source": source.rawValue,
"action_position": position,
]
if let provider = shareProvider {
eventPing["share_provider"] = provider
}
eventPing.merge(with: baseASPing)
eventsTracker.sendPing(eventPing as [String : AnyObject], validate: true)
}
func reportSessionStop(_ duration: UInt64) {
sessionsTracker.sendPing([
"session_duration": NSNumber(value: duration),
"app_version": AppInfo.appVersion,
"build": AppInfo.buildNumber,
"locale": Locale.current.identifier,
"release_channel": AppConstants.BuildChannel.rawValue
] as [String: Any], validate: true)
}
}
// MARK: - Section Header View
struct ASHeaderViewUX {
static let SeperatorColor = UIColor(rgb: 0xedecea)
static let TextFont = DynamicFontHelper.defaultHelper.MediumSizeBoldFontAS
static let SeperatorHeight = 1
static let Insets: CGFloat = UIDevice.current.userInterfaceIdiom == .pad ? ASPanelUX.SectionInsetsForIpad + ASPanelUX.MinimumInsets : ASPanelUX.MinimumInsets
static let TitleTopInset: CGFloat = 5
}
class ASFooterView: UICollectionReusableView {
override init(frame: CGRect) {
super.init(frame: frame)
let seperatorLine = UIView()
seperatorLine.backgroundColor = ASHeaderViewUX.SeperatorColor
self.backgroundColor = UIColor.clear
addSubview(seperatorLine)
seperatorLine.snp.makeConstraints { make in
make.height.equalTo(ASHeaderViewUX.SeperatorHeight)
make.leading.equalTo(self.snp.leading)
make.trailing.equalTo(self.snp.trailing)
make.top.equalTo(self.snp.top)
}
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
class ASHeaderView: UICollectionReusableView {
lazy fileprivate var titleLabel: UILabel = {
let titleLabel = UILabel()
titleLabel.text = self.title
titleLabel.textColor = UIColor.gray
titleLabel.font = ASHeaderViewUX.TextFont
titleLabel.minimumScaleFactor = 0.6
titleLabel.numberOfLines = 1
titleLabel.adjustsFontSizeToFitWidth = true
return titleLabel
}()
lazy var moreButton: UIButton = {
let button = UIButton()
button.setTitle("More", for: .normal)
button.isHidden = true
button.titleLabel?.font = ASHeaderViewUX.TextFont
button.contentHorizontalAlignment = .right
button.setTitleColor(UIConstants.SystemBlueColor, for: .normal)
button.setTitleColor(.gray, for: UIControlState.highlighted)
return button
}()
var title: String? {
willSet(newTitle) {
titleLabel.text = newTitle
}
}
var leftConstraint: Constraint?
var rightConstraint: Constraint?
var titleInsets: CGFloat {
get {
return UIScreen.main.bounds.size.width == self.frame.size.width && UIDevice.current.userInterfaceIdiom == .pad ? ASHeaderViewUX.Insets : ASPanelUX.MinimumInsets
}
}
override func prepareForReuse() {
super.prepareForReuse()
moreButton.isHidden = true
moreButton.removeTarget(nil, action: nil, for: .allEvents)
}
override init(frame: CGRect) {
super.init(frame: frame)
addSubview(titleLabel)
addSubview(moreButton)
moreButton.snp.makeConstraints { make in
make.top.equalTo(self).inset(ASHeaderViewUX.TitleTopInset)
make.bottom.equalTo(self)
self.rightConstraint = make.trailing.equalTo(self).inset(-titleInsets).constraint
}
moreButton.setContentCompressionResistancePriority(UILayoutPriorityRequired, for: UILayoutConstraintAxis.horizontal)
titleLabel.snp.makeConstraints { make in
self.leftConstraint = make.leading.equalTo(self).inset(titleInsets).constraint
make.trailing.equalTo(moreButton.snp.leading).inset(-ASHeaderViewUX.TitleTopInset)
make.top.equalTo(self).inset(ASHeaderViewUX.TitleTopInset)
make.bottom.equalTo(self)
}
}
override func layoutSubviews() {
super.layoutSubviews()
leftConstraint?.update(offset: titleInsets)
rightConstraint?.update(offset: -titleInsets)
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
open class PinnedSite: Site {
let isPinnedSite = true
init(site: Site) {
super.init(url: site.url, title: site.title, bookmarked: site.bookmarked)
self.icon = site.icon
self.metadata = site.metadata
}
}