Skip to content

Commit

Permalink
Merge branch 'trunk' into task/show-store-product-view-controller-for…
Browse files Browse the repository at this point in the history
…-downloading-jetpack
  • Loading branch information
staskus committed Nov 22, 2023
2 parents 583fbc3 + 6dd5cdc commit cd83651
Show file tree
Hide file tree
Showing 15 changed files with 906 additions and 399 deletions.
3 changes: 2 additions & 1 deletion RELEASE-NOTES.txt
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
23.8
-----

* [*] Fix the media item details screen layout on iPad [#22042]
* [*] Integrate native photos picker (`PHPickerViewControlle`) in Story Editor [#22059]
* [*] [internal] Fix an issue with scheduling of posts not working on iOS 17 with Xcode 15 [#22012]
* [*] [internal] Remove SDWebImage dependency from the app and improve cache cost calculation for GIFs [#21285]
* [*] Stats: Fix an issue where sites for clicked URLs do not open [#22061]

23.7
-----
Expand Down
11 changes: 9 additions & 2 deletions WordPress/Classes/Services/MediaImageService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,10 @@ extension MediaImageService {
/// The small thumbnail that can be used in collection view cells and
/// similar situations.
case small

/// A medium thumbnail thumbnail that can typically be used to fit
/// the entire screen on iPhone or a large portion of the sreen on iPad.
case medium
}

/// Returns an optimal target size in pixels for a thumbnail of the given
Expand All @@ -224,19 +228,22 @@ extension MediaImageService {
/// different screens and presentation modes to avoid fetching and caching
/// more than one version of the same image.
private static func getPreferredThumbnailSize(for thumbnail: ThumbnailSize) -> CGSize {
let minScreenSide = min(UIScreen.main.bounds.width, UIScreen.main.bounds.height)
switch thumbnail {
case .small:
/// The size is calculated to fill a collection view cell, assuming the app
/// displays a 4 or 5 cells in one row. The cell size can vary depending
/// on whether the device is in landscape or portrait mode, but the thumbnail size is
/// guaranteed to always be the same across app launches and optimized for
/// a portraint (dominant) mode.
let screenSide = min(UIScreen.main.bounds.width, UIScreen.main.bounds.height)
let itemPerRow = UIDevice.current.userInterfaceIdiom == .pad ? 5 : 4
let availableWidth = screenSide - SiteMediaCollectionViewController.spacing * CGFloat(itemPerRow - 1)
let availableWidth = minScreenSide - SiteMediaCollectionViewController.spacing * CGFloat(itemPerRow - 1)
let targetSide = (availableWidth / CGFloat(itemPerRow)).rounded(.down)
let targetSize = CGSize(width: targetSide, height: targetSide)
return targetSize.scaled(by: UIScreen.main.scale)
case .medium:
let side = min(1024, minScreenSide * UIScreen.main.scale)
return CGSize(width: side, height: side)
}
}

Expand Down
241 changes: 241 additions & 0 deletions WordPress/Classes/Utility/PageTree.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,241 @@
final class PageTree {

// A node in a tree, which of course is also a tree itself.
private class TreeNode {
let page: Page
var children = [TreeNode]()
var parentNode: TreeNode?

init(page: Page, children: [TreeNode] = [], parentNode: TreeNode? = nil) {
self.page = page
self.children = children
self.parentNode = parentNode
}

// The `PageTree` type is used to loaded
// Some page There are pages They are pages that doesn't belong to the root level, but their parent pages haven't been loaded yet.
var isOrphan: Bool {
(page.parentID?.int64Value ?? 0) > 0 && parentNode == nil
}

func dfsList() -> [Page] {
var pages = [Page]()
_ = depthFirstSearch { level, node in
node.page.hierarchyIndex = level
node.page.hasVisibleParent = node.parentNode != nil
pages.append(node.page)
return false
}
return pages
}

/// Perform depth-first search starting with the current (`self`) node.
///
/// - Parameter closure: A closure that takes a node and its level in the page tree as arguments and returns
/// a boolean value indicate whether the search should be stopped.
/// - Returns: `true` if search has been stopped by the closure.
@discardableResult
func depthFirstSearch(using closure: (Int, TreeNode) -> Bool) -> Bool {
depthFirstSearch(level: 0, using: closure)
}

private func depthFirstSearch(level: Int, using closure: (Int, TreeNode) -> Bool) -> Bool {
let shouldStop = closure(level, self)
if shouldStop {
return true
}

for child in children {
let shouldStop = child.depthFirstSearch(level: level + 1, using: closure)
if shouldStop {
return true
}
}

return false
}

/// Perform breadth-first search starting with the current (`self`) node.
///
/// - Parameter closure: A closure that takes a node as argument and returns a boolean value indicate whether
/// the search should be stopped.
/// - Returns: `true` if search has been stopped by the closure.
func breadthFirstSearch(using closure: (TreeNode) -> Bool) {
var queue = [TreeNode]()
queue.append(self)
while let current = queue.popLast() {
let shouldStop = closure(current)
if shouldStop {
break
}

queue.append(contentsOf: current.children)
}
}

func add(_ newNodes: [TreeNode], parentID: NSNumber) -> Bool {
assert(parentID != 0)

return depthFirstSearch { _, node in
if node.page.postID == parentID {
node.children.append(contentsOf: newNodes)
newNodes.forEach { $0.parentNode = node }
return true
}
return false
}
}
}

// The top level (or root level) pages, or nodes.
// They can be two types node:
// - child nodes. They are top level pages.
// - orphan nodes. They are pages that doesn't belong to the root level, but their parent pages haven't been loaded yet.
private var nodes = [TreeNode]()

// `orphanNodes` contains indexes of orphan nodes in the `nodes` array (the value part in the dictionary), which are
// grouped using their parent id (the key part in the dictionary).
// IMPORTANT: Make sure `orphanNodes` is up-to-date after the `nodes` array is modified.
private var orphanNodes = [NSNumber: [Int]]()

/// Add *new pages* to the page tree.
///
/// This function assumes none of array elements already exists in the current page tree.
func add(_ newPages: [Page]) {
let newNodes = newPages.map { TreeNode(page: $0) }
relocateOrphans(to: newNodes)

// First try to constrcuture a smaller subtree from the given pages, then move the new subtree to the existing
// page tree (`self`).
// The number of pages in a subtree can be changed if we want to futher tweak the performance.
let batch = 100
for index in stride(from: 0, to: newNodes.count, by: batch) {
let tree = PageTree()
tree.add(Array(newNodes[index..<min(index + batch, newNodes.count)]))
merge(subtree: tree)
}
}

/// Find the existing orphan nodes' parents in the given new nodes list argument and move them under their parent
/// node if found.
private func relocateOrphans(to newNodes: [TreeNode]) {
let relocated = orphanNodes.reduce(into: IndexSet()) { result, element in
let parentID = element.key
let indexes = element.value

let toBeRelocated = indexes.map { nodes[$0] }
let moved = newNodes.contains {
$0.add(toBeRelocated, parentID: parentID)
}
if moved {
result.formUnion(IndexSet(indexes))
}
}

if !relocated.isEmpty {
nodes.remove(atOffsets: relocated)
orphanNodes = nodes.enumerated().reduce(into: [:]) { indexes, node in
if node.element.isOrphan {
let parentID = node.element.page.parentID ?? 0
indexes[parentID, default: []].append(node.offset)
}
}
}
}

private func add(_ newNodes: [TreeNode]) {
newNodes.forEach { newNode in
let parentID = newNode.page.parentID ?? 0

// If the new node is at the root level, then simply add it as a child.
if parentID == 0 {
nodes.append(newNode)
return
}

// The new node is not at the root level, find its parent in the root level nodes.
for child in nodes {
if child.add([newNode], parentID: parentID) {
break
}
}

// Still not find their parent, add it to the root level nodes.
if newNode.parentNode == nil {
nodes.append(newNode)
orphanNodes[parentID, default: []].append(nodes.count - 1)
}
}
}

/// Move all the nodes in the given argument to the current page tree.
private func merge(subtree: PageTree) {
var parentIDs = subtree.nodes.reduce(into: Set()) { $0.insert($1.page.parentID ?? 0) }
// No need to look for root level
parentIDs.remove(0)
// Look up parent nodes upfront, to avoid repeated iteration for each node in `subtree`.
let parentNodes = findNodes(postIDs: parentIDs)

subtree.nodes.forEach { newNode in
let parentID = newNode.page.parentID ?? 0

// If the new node is at the root level, then simply add it as a child
if parentID == 0 {
nodes.append(newNode)
return
}

// The new node is not at the root level, find its parent in the root level nodes.
if let parentNode = parentNodes[parentID] {
parentNode.children.append(newNode)
newNode.parentNode = parentNode
} else {
// No parent found, add it to the root level nodes.
nodes.append(newNode)
orphanNodes[parentID, default: []].append(nodes.count - 1)
}
}
}

/// Find the node for the given page ids
private func findNodes(postIDs originalIDs: Set<NSNumber>) -> [NSNumber: TreeNode] {
guard !originalIDs.isEmpty else {
return [:]
}

var ids = originalIDs
var result = [NSNumber: TreeNode]()

// The new node is not at the root level, find its parent in the root level nodes.
for child in nodes {
if ids.isEmpty {
break
}

// Using BFS under the assumption that page tree in most sites is a shallow tree, where most pages are in top layers.
child.breadthFirstSearch { node in
let postID = node.page.postID ?? 0
let foundIndex = ids.firstIndex(of: postID)
if let foundIndex {
ids.remove(at: foundIndex)
result[postID] = node
}
return ids.isEmpty
}
}

return result
}

func hierarchyList() -> [Page] {
nodes.reduce(into: []) {
$0.append(contentsOf: $1.dfsList())
}
}

static func hierarchyList(of pages: [Page]) -> [Page] {
let tree = PageTree()
tree.add(pages)
return tree.hierarchyList()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,20 @@ enum DashboardCard: String, CaseIterable {
}
}

func shouldShow(for blog: Blog, apiResponse: BlogDashboardRemoteEntity? = nil) -> Bool {
func shouldShow(
for blog: Blog,
apiResponse: BlogDashboardRemoteEntity? = nil,
// The following three parameter should not have default values.
// Unfortunately, this method is called many times because the type is an enum with many cases^.
//
// At the time of writing, the priority is addressing a test failure and pave the way for better testability.
// As such, we are leaving default values to keep compatibility with the existing code.
//
// ^ – See the following article for a better way to distribute configurations https://www.jessesquires.com/blog/2016/07/31/enums-as-configs/
isJetpack: Bool = AppConfiguration.isJetpack,
isDotComAvailable: Bool = AccountHelper.isDotcomAvailable(),
shouldShowJetpackFeatures: Bool = JetpackFeaturesRemovalCoordinator.shouldShowJetpackFeatures()
) -> Bool {
switch self {
case .jetpackInstall:
return JetpackInstallPluginHelper.shouldShowCard(for: blog)
Expand All @@ -109,7 +122,11 @@ enum DashboardCard: String, CaseIterable {
case .failure:
return blog.dashboardState.isFirstLoadFailure
case .jetpackBadge:
return JetpackBrandingVisibility.all.enabled
return JetpackBrandingVisibility.all.isEnabled(
isWordPress: isJetpack == false,
isDotComAvailable: isDotComAvailable,
shouldShowJetpackFeatures: shouldShowJetpackFeatures
)
case .blaze:
return BlazeHelper.shouldShowCard(for: blog)
case .freeToPaidPlansDashboardCard:
Expand All @@ -127,7 +144,7 @@ enum DashboardCard: String, CaseIterable {
case .jetpackSocial:
return DashboardJetpackSocialCardCell.shouldShowCard(for: blog)
case .googleDomains:
return FeatureFlag.domainFocus.enabled && AppConfiguration.isJetpack
return FeatureFlag.domainFocus.enabled && isJetpack
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,23 @@ final class BlogDashboardService {
private let persistence: BlogDashboardPersistence
private let postsParser: BlogDashboardPostsParser
private let repository: UserPersistentRepository

init(managedObjectContext: NSManagedObjectContext,
remoteService: DashboardServiceRemote? = nil,
persistence: BlogDashboardPersistence = BlogDashboardPersistence(),
repository: UserPersistentRepository = UserDefaults.standard,
postsParser: BlogDashboardPostsParser? = nil) {
private let isJetpack: Bool
private let isDotComAvailable: Bool
private let shouldShowJetpackFeatures: Bool

init(
managedObjectContext: NSManagedObjectContext,
isJetpack: Bool,
isDotComAvailable: Bool,
shouldShowJetpackFeatures: Bool,
remoteService: DashboardServiceRemote? = nil,
persistence: BlogDashboardPersistence = BlogDashboardPersistence(),
repository: UserPersistentRepository = UserDefaults.standard,
postsParser: BlogDashboardPostsParser? = nil
) {
self.isJetpack = isJetpack
self.isDotComAvailable = isDotComAvailable
self.shouldShowJetpackFeatures = shouldShowJetpackFeatures
self.remoteService = remoteService ?? DashboardServiceRemote(wordPressComRestApi: WordPressComRestApi.defaultApi(in: managedObjectContext, localeKey: WordPressComRestApi.LocaleKeyV2))
self.persistence = persistence
self.repository = repository
Expand Down Expand Up @@ -87,10 +98,20 @@ private extension BlogDashboardService {
func parse(_ entity: BlogDashboardRemoteEntity?, blog: Blog, dotComID: Int) -> [DashboardCardModel] {
let personalizationService = BlogDashboardPersonalizationService(repository: repository, siteID: dotComID)
var cards: [DashboardCardModel] = DashboardCard.allCases.compactMap { card in
guard personalizationService.isEnabled(card),
card.shouldShow(for: blog, apiResponse: entity) else {
guard personalizationService.isEnabled(card) else {
return nil
}

guard card.shouldShow(
for: blog,
apiResponse: entity,
isJetpack: isJetpack,
isDotComAvailable: isDotComAvailable,
shouldShowJetpackFeatures: shouldShowJetpackFeatures
) else {
return nil
}

return DashboardCardModel(cardType: card, dotComID: dotComID, entity: entity)
}
if cards.isEmpty || cards.map(\.cardType) == [.personalize] {
Expand Down
Loading

0 comments on commit cd83651

Please sign in to comment.