diff --git a/Tests/KeystoneTests/Tests/Features/CustomPostTypes/BlogCustomPostTypesTests.swift b/Tests/KeystoneTests/Tests/Features/CustomPostTypes/BlogCustomPostTypesTests.swift new file mode 100644 index 000000000000..f578fa236405 --- /dev/null +++ b/Tests/KeystoneTests/Tests/Features/CustomPostTypes/BlogCustomPostTypesTests.swift @@ -0,0 +1,40 @@ +import Testing + +@testable import WordPress +@testable import WordPressData + +@MainActor +struct BlogCustomPostTypesTests { + @Test("uses Custom Post Types views when XML-RPC is disabled on self-hosted") + func usesCustomPostTypeViews() { + let contextManager = ContextManager.forTesting() + let blog = BlogBuilder(contextManager.mainContext) + .isNotHostedAtWPcom() + .build() + blog.account = nil + blog.isXMLRPCDisabled = true + + #expect(blog.usesCustomPostTypeViewsForPostsAndPages) + } + + @Test("does not use Custom Post Types views when XML-RPC is enabled") + func doesNotUseCustomPostTypeViewsWithXMLRPC() { + let contextManager = ContextManager.forTesting() + let blog = BlogBuilder(contextManager.mainContext).build() + blog.isXMLRPCDisabled = false + + #expect(!blog.usesCustomPostTypeViewsForPostsAndPages) + } + + @Test("does not use Custom Post Types views for WordPress.com") + func doesNotUseCustomPostTypeViewsForWordPressCom() { + let contextManager = ContextManager.forTesting() + let blog = BlogBuilder(contextManager.mainContext) + .isHostedAtWPcom() + .withAnAccount() + .build() + blog.isXMLRPCDisabled = true + + #expect(!blog.usesCustomPostTypeViewsForPostsAndPages) + } +} diff --git a/Tests/KeystoneTests/Tests/Features/CustomPostTypes/MySiteCreateEditorRoutingTests.swift b/Tests/KeystoneTests/Tests/Features/CustomPostTypes/MySiteCreateEditorRoutingTests.swift deleted file mode 100644 index ab2d718209ae..000000000000 --- a/Tests/KeystoneTests/Tests/Features/CustomPostTypes/MySiteCreateEditorRoutingTests.swift +++ /dev/null @@ -1,40 +0,0 @@ -import Testing - -@testable import WordPress -@testable import WordPressData - -@MainActor -struct MySiteCreateEditorRoutingTests { - - @Test("uses Core REST when XML-RPC is disabled on a self-hosted site") - func usesCoreRESTForXMLRPCDisabledSelfHostedSite() { - let context = ContextManager.forTesting().mainContext - let blog = BlogBuilder(context).build() - blog.isXMLRPCDisabled = true - - #expect(MySiteViewController.shouldUseCoreRESTEditor(for: blog)) - } - - @Test("uses the existing editor when XML-RPC is enabled on a self-hosted site") - func usesExistingEditorForXMLRPCEnabledSelfHostedSite() { - let context = ContextManager.forTesting().mainContext - let blog = BlogBuilder(context).build() - blog.isXMLRPCDisabled = false - - #expect(!MySiteViewController.shouldUseCoreRESTEditor(for: blog)) - } - - @Test("uses the existing editor for a WordPress.com site") - func usesExistingEditorForWordPressComSite() { - let context = ContextManager.forTesting().mainContext - let blog = BlogBuilder(context).isHostedAtWPcom().withAnAccount().build() - blog.isXMLRPCDisabled = true - - #expect(!MySiteViewController.shouldUseCoreRESTEditor(for: blog)) - } - - @Test("uses the existing editor when there is no selected site") - func usesExistingEditorWithoutSelectedSite() { - #expect(!MySiteViewController.shouldUseCoreRESTEditor(for: nil)) - } -} diff --git a/Tests/KeystoneTests/Tests/Features/CustomPostTypes/PostEditorRouterTests.swift b/Tests/KeystoneTests/Tests/Features/CustomPostTypes/PostEditorRouterTests.swift new file mode 100644 index 000000000000..74b7f48c2f59 --- /dev/null +++ b/Tests/KeystoneTests/Tests/Features/CustomPostTypes/PostEditorRouterTests.swift @@ -0,0 +1,245 @@ +import Foundation +import Testing + +@testable import WordPress +@testable import WordPressData + +@MainActor +struct PostEditorRouterTests { + @Test("selects Core REST for sites using Custom Post Types views") + func selectsCoreREST() { + let contextManager = ContextManager.forTesting() + let blog = BlogBuilder(contextManager.mainContext).build() + blog.isXMLRPCDisabled = true + #expect(PostEditorRouter.destination(for: blog) == .coreREST) + } + + @Test("selects legacy for other sites") + func selectsLegacy() { + let contextManager = ContextManager.forTesting() + let blog = BlogBuilder(contextManager.mainContext).build() + blog.isXMLRPCDisabled = false + #expect(PostEditorRouter.destination(for: blog) == .legacy) + } + + @Test("converts title content and tags for Core REST") + func convertsCoreRESTValues() throws { + let contextManager = ContextManager.forTesting() + let blog = BlogBuilder(contextManager.mainContext).build() + let context = NewPostEditorContext( + title: "Title", + content: "

Body

", + tags: "swift, ios" + ) + + #expect(try context.makeEditorContent().title == "Title") + #expect(try context.makeEditorContent().content.contains("Body")) + #expect( + context.makePostSettings(for: blog).tags == [ + PostSettings.Term(id: 0, name: "swift"), + PostSettings.Term(id: 0, name: "ios") + ] + ) + } + + @Test("uses voice output instead of seeded content") + func usesVoiceContent() throws { + let context = NewPostEditorContext(content: "old", voiceContent: "voice blocks") + + #expect(try context.makeEditorContent().content == "voice blocks") + } + + @Test("preserves initial media order as Core REST blocks") + func preservesMediaOrder() throws { + let contextManager = ContextManager.forTesting() + let first = MediaBuilder(contextManager.mainContext).build() + first.mediaID = 1 + first.mediaType = .image + first.remoteURL = "https://example.com/one.jpg" + let second = MediaBuilder(contextManager.mainContext).build() + second.mediaID = 2 + second.mediaType = .video + second.remoteURL = "https://example.com/two.mp4" + + let html = try NewPostEditorContext(initialMedia: [first, second]) + .makeEditorContent() + .content + let firstRange = try #require(html.range(of: "one.jpg")) + let secondRange = try #require(html.range(of: "two.mp4")) + + #expect(firstRange.lowerBound < secondRange.lowerBound) + #expect(html.contains(""# + ) + ) + #expect( + html.contains( + #"July Summary.pdf"# + ) + ) + } + + @Test("preserves blogging prompts through both editor adapters") + func preservesBloggingPrompt() throws { + let contextManager = ContextManager.forTesting() + let blog = BlogBuilder(contextManager.mainContext).build() + let prompt = try #require(BloggingPrompt.newObject(in: contextManager.mainContext)) + prompt.promptID = 42 + prompt.text = "What did you learn today?" + prompt.additionalPostTags = ["learning", "daily"] + let context = NewPostEditorContext(prompt: prompt) + + let settings = context.makePostSettings(for: blog) + #expect(settings.bloggingPromptID == "42") + #expect( + settings.makeCreateParameters().meta? + .valueForKey( + key: "_jetpack_blogging_prompt_key" + ) == .string("42") + ) + #expect(try context.makeEditorContent().content.contains(prompt.text)) + #expect( + settings.tags.map(\.name) == [ + "dailyprompt", "dailyprompt-42", "learning", "daily" + ] + ) + + let post = blog.createDraftPost() + context.applyLegacyValues(to: post) + #expect(post.bloggingPromptID == "42") + #expect(post.content?.contains(prompt.text) == true) + #expect(post.tags == "dailyprompt, dailyprompt-42, learning, daily") + } + + @Test("builds Core REST editor session attribution without a post") + func buildsCoreRESTEditorSessionAttribution() { + let contextManager = ContextManager.forTesting() + let blog = BlogBuilder(contextManager.mainContext).build() + blog.dotComID = 42 + let entryPoint = PostEditorEntryPoint.bloggingPromptsDashboardCard + + let session = PostEditorAnalyticsSession( + editor: .gutenbergKit, + blog: blog, + postType: "post", + contentType: .new, + entryPoint: entryPoint + ) + + #expect(session.blogID == 42) + #expect(session.postType == "post") + #expect(session.contentType == PostEditorAnalyticsSession.ContentType.new.rawValue) + #expect(session.entryPoint == entryPoint) + #expect(session.started == false) + } + + @Test("runs the Core REST dismissal callback once after view disappearance") + func callsCoreRESTDismissalOnceAfterViewDisappears() { + var callbackCount = 0 + let controller = CoreRESTPostEditorHostingController { + callbackCount += 1 + } + + #expect(callbackCount == 0) + controller.viewDidDisappear(false) + controller.viewDidDisappear(false) + #expect(callbackCount == 1) + } + + @Test("applies values through the legacy adapter") + func appliesLegacyPostValues() { + let contextManager = ContextManager.forTesting() + let blog = BlogBuilder(contextManager.mainContext).build() + let context = NewPostEditorContext(title: "Title", content: "Body", tags: "swift") + let post = blog.createDraftPost() + context.applyLegacyValues(to: post) + + #expect(post.postTitle == "Title") + #expect(post.content == "Body") + #expect(post.tags == "swift") + } +} diff --git a/WordPress/Classes/Apps/Reader/Home/ReaderHomeViewController.swift b/WordPress/Classes/Apps/Reader/Home/ReaderHomeViewController.swift index 2aa81b509acb..8e017fb49964 100644 --- a/WordPress/Classes/Apps/Reader/Home/ReaderHomeViewController.swift +++ b/WordPress/Classes/Apps/Reader/Home/ReaderHomeViewController.swift @@ -16,7 +16,12 @@ final class ReaderHomeViewController: ReaderStreamViewController { titleView.textLabel.text = SharedStrings.Reader.home navigationItem.titleView = titleView - navigationItem.rightBarButtonItem = UIBarButtonItem(image: UIImage(named: "wpl-add-card")?.resized(to: CGSize(width: 28, height: 28)), style: .plain, target: self, action: #selector(buttonCreatePostTapped)) + navigationItem.rightBarButtonItem = UIBarButtonItem( + image: UIImage(named: "wpl-add-card")?.resized(to: CGSize(width: 28, height: 28)), + style: .plain, + target: self, + action: #selector(buttonCreatePostTapped) + ) } override func headerForStream(_ topic: ReaderAbstractTopic?, container: UITableViewController) -> UIView? { @@ -44,12 +49,18 @@ final class ReaderHomeViewController: ReaderStreamViewController { } private func showCreatePostScreen(blog: Blog) { - let editorVC = EditPostViewController(blog: blog) - editorVC.entryPoint = .dashboard - present(editorVC, animated: true) + PostEditorRouter.showNewPost( + for: blog, + from: self, + context: NewPostEditorContext(entryPoint: .dashboard) + ) } } private enum Strings { - static let homeDetails = NSLocalizedString("reader.home.header.details", value: "Stay current with the blogs you've subscribed to.", comment: "Screen header details") + static let homeDetails = NSLocalizedString( + "reader.home.header.details", + value: "Stay current with the blogs you've subscribed to.", + comment: "Screen header details" + ) } diff --git a/WordPress/Classes/Extensions/Post+BloggingPrompts.swift b/WordPress/Classes/Extensions/Post+BloggingPrompts.swift index b2e8ef7963c7..bd562f5b0513 100644 --- a/WordPress/Classes/Extensions/Post+BloggingPrompts.swift +++ b/WordPress/Classes/Extensions/Post+BloggingPrompts.swift @@ -1,50 +1,33 @@ import Foundation import WordPressData -extension Post { - - func prepareForPrompt(_ prompt: BloggingPrompt?) { - guard let prompt else { - return - } - - content = promptContent(withPromptText: prompt.text) - bloggingPromptID = String(prompt.promptID) - - if let currentTags = tags { - tags = "\(currentTags), \(Strings.promptTag)" - } else { - tags = Strings.promptTag - } - - tags?.append(", \(Strings.promptTag)-\(prompt.promptID)") - - // add any additional tags for the prompt. - if let additionalPostTags = prompt.additionalPostTags, !additionalPostTags.isEmpty { - additionalPostTags - .map { ", \($0)" } - .forEach { self.tags?.append($0) } - } +extension BloggingPrompt { + var editorContent: String { + """ + +

\(text)

+ + +

+ + """ } - private func promptContent(withPromptText promptText: String) -> String { - return pullquoteBlock(promptText: promptText) + Strings.emptyParagraphBlock + var editorTags: [String] { + [Post.Strings.promptTag, "\(Post.Strings.promptTag)-\(promptID)"] + (additionalPostTags ?? []) } +} - private func pullquoteBlock(promptText: String) -> String { - return """ - -

\(promptText)

- - """ +extension Post { + func prepareForPrompt(_ prompt: BloggingPrompt?) { + guard let prompt else { return } + content = prompt.editorContent + bloggingPromptID = String(prompt.promptID) + tags = (AbstractPost.makeTags(from: tags ?? "") + prompt.editorTags) + .joined(separator: ", ") } enum Strings { static let promptTag = "dailyprompt" - fileprivate static let emptyParagraphBlock = """ - -

- - """ } } diff --git a/WordPress/Classes/System/3D Touch/WP3DTouchShortcutHandler.swift b/WordPress/Classes/System/3D Touch/WP3DTouchShortcutHandler.swift index 586e22c197a2..7a9ada09dedf 100644 --- a/WordPress/Classes/System/3D Touch/WP3DTouchShortcutHandler.swift +++ b/WordPress/Classes/System/3D Touch/WP3DTouchShortcutHandler.swift @@ -10,7 +10,7 @@ open class WP3DTouchShortcutHandler: NSObject { case Notifications var type: String { - return Bundle.main.bundleIdentifier! + ".\(self.rawValue)" + Bundle.main.bundleIdentifier! + ".\(self.rawValue)" } } @@ -20,27 +20,32 @@ open class WP3DTouchShortcutHandler: NSObject { let rootViewPresenter = RootViewCoordinator.sharedPresenter switch shortcutItem.type { - case ShortcutIdentifier.LogIn.type: - WPAnalytics.track(.shortcutLogIn) - return true - case ShortcutIdentifier.NewPost.type: - WPAnalytics.track(.shortcutNewPost) - rootViewPresenter.showPostEditor(animated: false) - return true - case ShortcutIdentifier.Stats.type: - WPAnalytics.track(.shortcutStats) - clearCurrentViewController(rootViewPresenter.rootViewController) - if let mainBlog = Blog.lastUsedOrFirst(in: ContextManager.shared.mainContext) { - rootViewPresenter.showStats(for: mainBlog, source: .shortcut) - } - return true - case ShortcutIdentifier.Notifications.type: - WPAnalytics.track(.shortcutNotifications) - clearCurrentViewController(rootViewPresenter.rootViewController) - rootViewPresenter.showNotificationsTab() - return true - default: - return false + case ShortcutIdentifier.LogIn.type: + WPAnalytics.track(.shortcutLogIn) + return true + case ShortcutIdentifier.NewPost.type: + WPAnalytics.track(.shortcutNewPost) + rootViewPresenter.showNewPostEditor( + context: NewPostEditorContext( + analytics: .editorCreatedPost(source: "create_button", postType: "post"), + animated: false + ) + ) + return true + case ShortcutIdentifier.Stats.type: + WPAnalytics.track(.shortcutStats) + clearCurrentViewController(rootViewPresenter.rootViewController) + if let mainBlog = Blog.lastUsedOrFirst(in: ContextManager.shared.mainContext) { + rootViewPresenter.showStats(for: mainBlog, source: .shortcut) + } + return true + case ShortcutIdentifier.Notifications.type: + WPAnalytics.track(.shortcutNotifications) + clearCurrentViewController(rootViewPresenter.rootViewController) + rootViewPresenter.showNotificationsTab() + return true + default: + return false } } diff --git a/WordPress/Classes/System/Root View/RootViewPresenter+EditorNavigation.swift b/WordPress/Classes/System/Root View/RootViewPresenter+EditorNavigation.swift index 722d42884ba0..feee1fdf3f96 100644 --- a/WordPress/Classes/System/Root View/RootViewPresenter+EditorNavigation.swift +++ b/WordPress/Classes/System/Root View/RootViewPresenter+EditorNavigation.swift @@ -1,7 +1,5 @@ import Foundation -import SwiftUI import WordPressData -import WordPressShared extension RootViewPresenter { func currentOrLastBlog() -> Blog? { @@ -12,120 +10,69 @@ extension RootViewPresenter { return Blog.lastUsedOrFirst(in: context) } - func showPostEditor( - animated: Bool = true, - post: Post? = nil, + func showNewPostEditor( blog: Blog? = nil, - completion afterDismiss: (() -> Void)? = nil + context: NewPostEditorContext = .init( + analytics: .editorCreatedPost(source: "create_button", postType: "post") + ) ) { if rootViewController.presentedViewController != nil { rootViewController.dismiss(animated: false) } - - guard let blog = blog ?? currentOrLastBlog() else { - return + guard let blog = blog ?? currentOrLastBlog() else { return } + MainActor.assumeIsolated { + PostEditorRouter.showNewPost(for: blog, from: rootViewController, context: context) } - - let editor: EditPostViewController - if let post { - editor = EditPostViewController(post: post) - } else { - editor = EditPostViewController(blog: blog) - } - editor.modalPresentationStyle = .fullScreen - editor.showImmediately = !animated - editor.afterDismiss = afterDismiss - - let properties = [WPAppAnalyticsKeyTapSource: "create_button", WPAppAnalyticsKeyPostType: "post"] - WPAppAnalytics.track(.editorCreatedPost, properties: properties, blog: blog) - rootViewController.present(editor, animated: false) } - func showCoreRESTPostEditor(blog: Blog) { + func showPostEditor( + post: Post, + animated: Bool = true, + completion afterDismiss: (() -> Void)? = nil + ) { if rootViewController.presentedViewController != nil { rootViewController.dismiss(animated: false) } - - let properties = [WPAppAnalyticsKeyTapSource: "create_button", WPAppAnalyticsKeyPostType: "post"] - WPAppAnalytics.track(.editorCreatedPost, properties: properties, blog: blog) - presentCoreRESTEditor(blog: blog, postType: .posts) + let editor = EditPostViewController(post: post) + editor.showImmediately = !animated + editor.afterDismiss = afterDismiss + rootViewController.present(editor, animated: false) } - /// - parameter blog: Blog to a add a page to. Uses the current or last blog if not provided - func showPageEditor( + func showNewPageEditor( blog: Blog? = nil, - title: String? = nil, - content: String? = nil, - source: String = "create_button" + context: NewPostEditorContext = .init( + analytics: .editorCreatedPage(source: "create_button") + ) ) { - - // If we are already showing a view controller, dismiss and show the editor afterward guard rootViewController.presentedViewController == nil else { rootViewController.dismiss(animated: true) { [weak self] in - self?.showPageEditor(blog: blog, title: title, content: content, source: source) + self?.showNewPageEditor(blog: blog, context: context) } return } - guard let blog = blog ?? self.currentOrLastBlog() else { - return - } - guard content == nil else { - showEditor(blog: blog, title: title, content: content) + guard let blog = blog ?? currentOrLastBlog() else { return } + guard context.content == nil else { + MainActor.assumeIsolated { + PostEditorRouter.showNewPage(for: blog, from: rootViewController, context: context) + } return } - WPAnalytics.track( - WPAnalyticsEvent.editorCreatedPage, - properties: [WPAppAnalyticsKeyTapSource: source], - blog: blog - ) - PageCoordinator.showLayoutPickerIfNeeded(from: rootViewController, forBlog: blog) { - [weak self] selectedLayout in - self?.showEditor(blog: blog, title: selectedLayout?.title, content: selectedLayout?.content) + var context = context + MainActor.assumeIsolated { + context.trackAnalytics(for: blog) } - } - - func showCoreRESTPageEditor(blog: Blog, source: String = "create_button") { - guard rootViewController.presentedViewController == nil else { - rootViewController.dismiss(animated: true) { [weak self] in - self?.showCoreRESTPageEditor(blog: blog, source: source) - } - return - } - WPAnalytics.track( - WPAnalyticsEvent.editorCreatedPage, - properties: [WPAppAnalyticsKeyTapSource: source], - blog: blog - ) + context.analytics = .none PageCoordinator.showLayoutPickerIfNeeded(from: rootViewController, forBlog: blog) { [weak self] selectedLayout in - let initialContent = selectedLayout.map { - EditorContent(title: $0.title ?? "", content: $0.content) + guard let self else { return } + var context = context + context.title = selectedLayout?.title + context.content = selectedLayout?.content + MainActor.assumeIsolated { + PostEditorRouter.showNewPage(for: blog, from: self.rootViewController, context: context) } - self?.presentCoreRESTEditor(blog: blog, postType: .pages, initialContent: initialContent) } } - - private func presentCoreRESTEditor( - blog: Blog, - postType: PinnedPostType, - initialContent: EditorContent? = nil - ) { - let controller = UIHostingController(rootView: AnyView(EmptyView())) - controller.rootView = AnyView( - CoreRESTPostEditorRoute( - blog: blog, - postType: postType, - initialContent: initialContent, - presentingViewController: controller - ) - ) - controller.modalPresentationStyle = .fullScreen - rootViewController.present(controller, animated: true) - } - - private func showEditor(blog: Blog, title: String?, content: String?) { - let editorViewController = EditPageViewController(blog: blog, postTitle: title, content: content) - rootViewController.present(editorViewController, animated: false) - } } diff --git a/WordPress/Classes/System/WordPressAppDelegate+openURL.swift b/WordPress/Classes/System/WordPressAppDelegate+openURL.swift index d5e7f389c5dc..112437b4e347 100644 --- a/WordPress/Classes/System/WordPressAppDelegate+openURL.swift +++ b/WordPress/Classes/System/WordPressAppDelegate+openURL.swift @@ -155,19 +155,14 @@ import WordPressShared // Should more formats be accepted in the future, this line would have to be expanded to accomodate it. let contentEscaped = contentRaw.escapeHtmlNamedEntities() - let post = blog.createDraftPost() - post.postTitle = title - post.content = contentEscaped - post.tags = tags - - let postVC = EditPostViewController(post: post) - postVC.modalPresentationStyle = .fullScreen - - RootViewCoordinator.sharedPresenter.rootViewController.present(postVC, animated: true, completion: nil) - - WPAppAnalytics.track( - .editorCreatedPost, - withProperties: [WPAppAnalyticsKeyTapSource: "url_scheme", WPAppAnalyticsKeyPostType: "post"] + RootViewCoordinator.sharedPresenter.showNewPostEditor( + blog: blog, + context: NewPostEditorContext( + title: title, + content: contentEscaped, + tags: tags, + analytics: .editorCreatedPost(source: "url_scheme", postType: "post") + ) ) return true @@ -196,11 +191,13 @@ import WordPressShared // Should more formats be accepted be accepted in the future, this line would have to be expanded to accomodate it. let contentEscaped = contentRaw.escapeHtmlNamedEntities() - RootViewCoordinator.sharedPresenter.showPageEditor( + RootViewCoordinator.sharedPresenter.showNewPageEditor( blog: blog, - title: title, - content: contentEscaped, - source: "url_scheme" + context: NewPostEditorContext( + title: title, + content: contentEscaped, + analytics: .editorCreatedPage(source: "url_scheme") + ) ) return true diff --git a/WordPress/Classes/Utility/Universal Links/Route+Page.swift b/WordPress/Classes/Utility/Universal Links/Route+Page.swift index be9a6de556a0..f89407b7cd10 100644 --- a/WordPress/Classes/Utility/Universal Links/Route+Page.swift +++ b/WordPress/Classes/Utility/Universal Links/Route+Page.swift @@ -16,6 +16,6 @@ struct NewPageForSiteRoute: Route { struct NewPageNavigationAction: NavigationAction { func perform(_ values: [String: String], source: UIViewController? = nil, router: LinkRouter) { - RootViewCoordinator.sharedPresenter.showPageEditor(blog: blog(from: values)) + RootViewCoordinator.sharedPresenter.showNewPageEditor(blog: blog(from: values)) } } diff --git a/WordPress/Classes/Utility/Universal Links/Routes+Post.swift b/WordPress/Classes/Utility/Universal Links/Routes+Post.swift index cd009ca55e99..fa6612a1ac2f 100644 --- a/WordPress/Classes/Utility/Universal Links/Routes+Post.swift +++ b/WordPress/Classes/Utility/Universal Links/Routes+Post.swift @@ -16,6 +16,6 @@ struct NewPostForSiteRoute: Route { struct NewPostNavigationAction: NavigationAction { func perform(_ values: [String: String], source: UIViewController? = nil, router: LinkRouter) { - RootViewCoordinator.sharedPresenter.showPostEditor(blog: blog(from: values)) + RootViewCoordinator.sharedPresenter.showNewPostEditor(blog: blog(from: values)) } } diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Pages/PagesCardViewModel.swift b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Pages/PagesCardViewModel.swift index b2a663808365..d63657fa4e20 100644 --- a/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Pages/PagesCardViewModel.swift +++ b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Pages/PagesCardViewModel.swift @@ -57,7 +57,8 @@ class PagesCardViewModel: NSObject { typealias Snapshot = NSDiffableDataSourceSnapshot typealias PagesSnapshot = NSDiffableDataSourceSnapshot - lazy var diffableDataSource = DataSource(tableView: view!.tableView) { [weak self] tableView, indexPath, item -> UITableViewCell? in + lazy var diffableDataSource = DataSource(tableView: view!.tableView) { + [weak self] tableView, indexPath, item -> UITableViewCell? in guard let self else { return nil } @@ -67,11 +68,20 @@ class PagesCardViewModel: NSObject { case .ghost: return self.configureGhostCell(tableView: tableView, indexPath: indexPath) case .createPage(let compact, let hasPages): - return self.configureCreationCell(compact: compact, hasPages: hasPages, tableView: tableView, indexPath: indexPath) + return self.configureCreationCell( + compact: compact, + hasPages: hasPages, + tableView: tableView, + indexPath: indexPath + ) } } - init(blog: Blog, view: PagesCardView, managedObjectContext: NSManagedObjectContext = ContextManager.shared.mainContext) { + init( + blog: Blog, + view: PagesCardView, + managedObjectContext: NSManagedObjectContext = ContextManager.shared.mainContext + ) { self.blog = blog self.view = view self.managedObjectContext = managedObjectContext @@ -105,14 +115,28 @@ class PagesCardViewModel: NSObject { guard let viewController = view?.parentViewController else { return } + trackCreateSectionTapped() + var context = NewPostEditorContext( + analytics: .editorCreatedPage(source: Constants.analyticsPageCreationSource), + animated: false + ) + context.trackAnalytics(for: blog) + context.analytics = .none PageCoordinator.showLayoutPickerIfNeeded(from: viewController, forBlog: blog) { [weak self] selectedLayout in guard let blog = self?.blog else { return } - let editorViewController = EditPageViewController(blog: blog, postTitle: selectedLayout?.title, content: selectedLayout?.content) - viewController.present(editorViewController, animated: false) + var context = context + context.title = selectedLayout?.title + context.content = selectedLayout?.content + MainActor.assumeIsolated { + PostEditorRouter.showNewPage( + for: blog, + from: viewController, + context: context + ) + } } - trackCreateSectionTapped() } func trackPageTapped() { @@ -121,10 +145,6 @@ class PagesCardViewModel: NSObject { private func trackCreateSectionTapped() { trackCardItemTapped(itemType: Constants.analyticsCreationItemType) - - WPAnalytics.track(WPAnalyticsEvent.editorCreatedPage, - properties: [WPAppAnalyticsKeyTapSource: Constants.analyticsPageCreationSource], - blog: blog) } private func trackCardItemTapped(itemType: String) { @@ -132,9 +152,11 @@ class PagesCardViewModel: NSObject { Constants.analyticsTypeKey: DashboardCard.pages.rawValue, Constants.analyticsItemTypeKey: itemType ] - WPAnalytics.track(.dashboardCardItemTapped, - properties: properties, - blog: blog) + WPAnalytics.track( + .dashboardCardItemTapped, + properties: properties, + blog: blog + ) } func tearDown() { @@ -146,12 +168,18 @@ class PagesCardViewModel: NSObject { // MARK: Cells Configuration private extension PagesCardViewModel { - private func configurePageCell(objectID: NSManagedObjectID, tableView: UITableView, indexPath: IndexPath) -> UITableViewCell { + private func configurePageCell( + objectID: NSManagedObjectID, + tableView: UITableView, + indexPath: IndexPath + ) -> UITableViewCell { guard let page = try? self.managedObjectContext.existingObject(with: objectID) as? Page else { return UITableViewCell() } - let cell = tableView.dequeueReusableCell(withIdentifier: DashboardPageCell.defaultReuseID, for: indexPath) as? DashboardPageCell + let cell = + tableView.dequeueReusableCell(withIdentifier: DashboardPageCell.defaultReuseID, for: indexPath) + as? DashboardPageCell cell?.accessoryType = .none cell?.configure(using: page, rowIndex: indexPath.row) @@ -160,23 +188,38 @@ private extension PagesCardViewModel { } private func configureGhostCell(tableView: UITableView, indexPath: IndexPath) -> UITableViewCell { - let cell = tableView.dequeueReusableCell(withIdentifier: BlogDashboardPostCardGhostCell.defaultReuseID, for: indexPath) as? BlogDashboardPostCardGhostCell - let style = GhostStyle(beatDuration: GhostStyle.Defaults.beatDuration, - beatStartColor: UIAppColor.placeholderElement, - beatEndColor: UIAppColor.placeholderElementFaded) + let cell = + tableView.dequeueReusableCell(withIdentifier: BlogDashboardPostCardGhostCell.defaultReuseID, for: indexPath) + as? BlogDashboardPostCardGhostCell + let style = GhostStyle( + beatDuration: GhostStyle.Defaults.beatDuration, + beatStartColor: UIAppColor.placeholderElement, + beatEndColor: UIAppColor.placeholderElementFaded + ) cell?.contentView.stopGhostAnimation() cell?.contentView.startGhostAnimation(style: style) return cell ?? UITableViewCell() } - private func configureCreationCell(compact: Bool, hasPages: Bool, tableView: UITableView, indexPath: IndexPath) -> UITableViewCell { + private func configureCreationCell( + compact: Bool, + hasPages: Bool, + tableView: UITableView, + indexPath: IndexPath + ) -> UITableViewCell { var cell: DashboardPageCreationCell? if compact { - cell = tableView.dequeueReusableCell(withIdentifier: DashboardPageCreationCompactCell.defaultReuseID, - for: indexPath) as? DashboardPageCreationCell + cell = + tableView.dequeueReusableCell( + withIdentifier: DashboardPageCreationCompactCell.defaultReuseID, + for: indexPath + ) as? DashboardPageCreationCell } else { - cell = tableView.dequeueReusableCell(withIdentifier: DashboardPageCreationExpandedCell.defaultReuseID, - for: indexPath) as? DashboardPageCreationCell + cell = + tableView.dequeueReusableCell( + withIdentifier: DashboardPageCreationExpandedCell.defaultReuseID, + for: indexPath + ) as? DashboardPageCreationCell } cell?.viewModel = self cell?.configure(hasPages: hasPages) @@ -202,7 +245,12 @@ private extension PagesCardViewModel { fetchedResultsController?.delegate = nil fetchedResultsController = nil - fetchedResultsController = NSFetchedResultsController(fetchRequest: fetchRequest(), managedObjectContext: managedObjectContext, sectionNameKeyPath: nil, cacheName: nil) + fetchedResultsController = NSFetchedResultsController( + fetchRequest: fetchRequest(), + managedObjectContext: managedObjectContext, + sectionNameKeyPath: nil, + cacheName: nil + ) fetchedResultsController?.delegate = self } @@ -221,7 +269,7 @@ private extension PagesCardViewModel { } func sortDescriptorsForFetchRequest() -> [NSSortDescriptor] { - return filter.sortDescriptors + filter.sortDescriptors } func sync() { @@ -238,15 +286,18 @@ private extension PagesCardViewModel { // Only show loading state if there are no pages at all if numberOfPages == 0 && isSyncing { currentState = .loading - } - else { + } else { currentState = .loaded } } func trackCardDisplayedIfNeeded() { if currentState == .loaded { - BlogDashboardAnalytics.shared.track(.dashboardCardShown, properties: ["type": DashboardCard.pages.rawValue], blog: blog) + BlogDashboardAnalytics.shared.track( + .dashboardCardShown, + properties: ["type": DashboardCard.pages.rawValue], + blog: blog + ) } } @@ -263,12 +314,15 @@ private extension PagesCardViewModel { // MARK: DashboardPostsSyncManagerListener extension PagesCardViewModel: DashboardPostsSyncManagerListener { - func postsSynced(success: Bool, - blog: Blog, - postType: DashboardPostsSyncManager.PostType, - for statuses: [BasePost.Status]) { + func postsSynced( + success: Bool, + blog: Blog, + postType: DashboardPostsSyncManager.PostType, + for statuses: [BasePost.Status] + ) { guard postType == .page, - self.blog == blog else { + self.blog == blog + else { return } @@ -282,7 +336,10 @@ extension PagesCardViewModel: DashboardPostsSyncManagerListener { // MARK: - NSFetchedResultsControllerDelegate extension PagesCardViewModel: NSFetchedResultsControllerDelegate { - func controller(_ controller: NSFetchedResultsController, didChangeContentWith snapshot: NSDiffableDataSourceSnapshotReference) { + func controller( + _ controller: NSFetchedResultsController, + didChangeContentWith snapshot: NSDiffableDataSourceSnapshotReference + ) { guard let dataSource = view?.tableView.dataSource as? DataSource else { return } @@ -313,7 +370,8 @@ extension PagesCardViewModel: NSFetchedResultsControllerDelegate { var adjustedPagesSnapshot = pagesSnapshot // Delete extra pages - let sequenceToDelete = adjustedPagesSnapshot.itemIdentifiers.enumerated().filter { $0.offset > Constants.numberOfPages - 1 } + let sequenceToDelete = adjustedPagesSnapshot.itemIdentifiers.enumerated() + .filter { $0.offset > Constants.numberOfPages - 1 } let managedObjectIDsToDelete = sequenceToDelete.map { $0.element } adjustedPagesSnapshot.deleteItems(managedObjectIDsToDelete) @@ -322,15 +380,19 @@ extension PagesCardViewModel: NSFetchedResultsControllerDelegate { snapshot.appendItems(pageItems, toSection: .pages) // Reload items if needed - let reloadIdentifiers = identifiersToBeReloaded(newSnapshot: snapshot, - currentSnapshot: currentSnapshot, - pagesSnapshot: pagesSnapshot) + let reloadIdentifiers = identifiersToBeReloaded( + newSnapshot: snapshot, + currentSnapshot: currentSnapshot, + pagesSnapshot: pagesSnapshot + ) snapshot.reloadItems(reloadIdentifiers) // Add Create Page Item // Section should be compact if there are more than one pages. Should be expanded otherwise. - let createPageItem: PagesListItem = .createPage(compact: pageItems.hasMultiplePages, - hasPages: pageItems.hasPages) + let createPageItem: PagesListItem = .createPage( + compact: pageItems.hasMultiplePages, + hasPages: pageItems.hasPages + ) snapshot.appendItems([createPageItem], toSection: .create) case .loading: @@ -347,16 +409,23 @@ extension PagesCardViewModel: NSFetchedResultsControllerDelegate { /// - currentSnapshot: The old snapshot. Used to check if the item changed position /// - pagesSnapshot: Snapshot retrieved from CoreDate /// - Returns: Array of items that should be reloaded - private func identifiersToBeReloaded(newSnapshot: Snapshot, currentSnapshot: Snapshot, pagesSnapshot: PagesSnapshot) -> [PagesListItem] { - return newSnapshot.itemIdentifiers.compactMap { item in + private func identifiersToBeReloaded( + newSnapshot: Snapshot, + currentSnapshot: Snapshot, + pagesSnapshot: PagesSnapshot + ) -> [PagesListItem] { + newSnapshot.itemIdentifiers.compactMap { item in guard case PagesListItem.page(let objectID) = item, - let currentIndex = currentSnapshot.indexOfItem(item), - let index = pagesSnapshot.indexOfItem(objectID), - index == currentIndex else { + let currentIndex = currentSnapshot.indexOfItem(item), + let index = pagesSnapshot.indexOfItem(objectID), + index == currentIndex + else { return nil // No need to reload if the index changed } - guard let existingObject = try? fetchedResultsController?.managedObjectContext.existingObject(with: objectID), - existingObject.isUpdated else { + guard + let existingObject = try? fetchedResultsController?.managedObjectContext.existingObject(with: objectID), + existingObject.isUpdated + else { return nil // No need to reload if the object wasn't updated } return item @@ -372,10 +441,10 @@ extension PagesCardViewModel: NSFetchedResultsControllerDelegate { private extension Array where Element == PagesListItem { var hasPages: Bool { - return !isEmpty + !isEmpty } var hasMultiplePages: Bool { - return count > 1 + count > 1 } } diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Prompts/DashboardPromptsCardCell.swift b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Prompts/DashboardPromptsCardCell.swift index 3db7569b82d8..151add09f90b 100644 --- a/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Prompts/DashboardPromptsCardCell.swift +++ b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Prompts/DashboardPromptsCardCell.swift @@ -41,7 +41,7 @@ class DashboardPromptsCardCell: UICollectionViewCell, Reusable { } private lazy var bloggingPromptsService: BloggingPromptsService? = { - return BloggingPromptsService(blog: blog) + BloggingPromptsService(blog: blog) }() /// When set to true, a "default" version of the card is displayed. That is: @@ -103,7 +103,10 @@ class DashboardPromptsCardCell: UICollectionViewCell, Reusable { let view = UIView() view.translatesAutoresizingMaskIntoConstraints = false view.addSubview(promptLabel) - view.pinSubviewToAllEdges(promptLabel, insets: UIEdgeInsets(top: Constants.spacing, left: 0, bottom: 0, right: 0)) + view.pinSubviewToAllEdges( + promptLabel, + insets: UIEdgeInsets(top: Constants.spacing, left: 0, bottom: 0, right: 0) + ) return view }() @@ -164,8 +167,8 @@ class DashboardPromptsCardCell: UICollectionViewCell, Reusable { button.titleLabel?.font = WPStyleGuide.BloggingPrompts.answerInfoButtonFont button.setTitleColor( RemoteFeatureFlag.bloggingPromptsSocial.enabled() - ? WPStyleGuide.BloggingPrompts.buttonTitleColor - : WPStyleGuide.BloggingPrompts.answerInfoButtonColor, + ? WPStyleGuide.BloggingPrompts.buttonTitleColor + : WPStyleGuide.BloggingPrompts.answerInfoButtonColor, for: .normal ) button.titleLabel?.numberOfLines = 0 @@ -177,7 +180,8 @@ class DashboardPromptsCardCell: UICollectionViewCell, Reusable { @objc private func didTapAnswerInfoButton() { guard RemoteFeatureFlag.bloggingPromptsSocial.enabled(), - let promptID = prompt?.promptID else { + let promptID = prompt?.promptID + else { return } let tagName = "\(ReaderTagTopic.dailyPromptTag)-\(promptID)" @@ -229,7 +233,9 @@ class DashboardPromptsCardCell: UICollectionViewCell, Reusable { private lazy var attributionTrailingImage = UIImageView() private lazy var attributionStackView: UIStackView = { - let stackView = UIStackView(arrangedSubviews: [attributionIcon, attributionSourceLabel, attributionTrailingImage]) + let stackView = UIStackView(arrangedSubviews: [ + attributionIcon, attributionSourceLabel, attributionTrailingImage + ]) stackView.setCustomSpacing(Constants.attributionTrailingImageSpacing, after: attributionSourceLabel) stackView.alignment = .center @@ -338,15 +344,23 @@ class DashboardPromptsCardCell: UICollectionViewCell, Reusable { } private var contextMenu: UIMenu { - return .init(title: String(), options: .displayInline, children: contextMenuItems.map { menuSection in - UIMenu(title: String(), options: .displayInline, children: [ - // Use an uncached deferred element so we can track each time the menu is shown - UIDeferredMenuElement.uncached { completion in - WPAnalytics.track(.promptsDashboardCardMenu) - completion(menuSection.map { $0.toAction }) - } - ]) - }) + .init( + title: String(), + options: .displayInline, + children: contextMenuItems.map { menuSection in + UIMenu( + title: String(), + options: .displayInline, + children: [ + // Use an uncached deferred element so we can track each time the menu is shown + UIDeferredMenuElement.uncached { completion in + WPAnalytics.track(.promptsDashboardCardMenu) + completion(menuSection.map { $0.toAction }) + } + ] + ) + } + ) } // MARK: Initializers @@ -364,7 +378,8 @@ class DashboardPromptsCardCell: UICollectionViewCell, Reusable { override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { // refresh when the appearance style changed so the placeholder images are correctly recolored. if let previousTraitCollection, - previousTraitCollection.userInterfaceStyle != traitCollection.userInterfaceStyle { + previousTraitCollection.userInterfaceStyle != traitCollection.userInterfaceStyle + { refreshStackView() } } @@ -380,8 +395,9 @@ class DashboardPromptsCardCell: UICollectionViewCell, Reusable { // and therefore should not be shown. static func shouldShowCard(for blog: Blog) -> Bool { guard FeatureFlag.bloggingPrompts.enabled, - blog.isAccessibleThroughWPCom, - let promptsService = BloggingPromptsService(blog: blog) else { + blog.isAccessibleThroughWPCom, + let promptsService = BloggingPromptsService(blog: blog) + else { return false } @@ -449,10 +465,10 @@ private extension DashboardPromptsCardCell { func observeManagedObjectsChange() { NotificationCenter.default.addObserver( - self, - selector: #selector(handleObjectsChange), - name: .NSManagedObjectContextObjectsDidChange, - object: ContextManager.shared.mainContext + self, + selector: #selector(handleObjectsChange), + name: .NSManagedObjectContextObjectsDidChange, + object: ContextManager.shared.mainContext ) } @@ -477,36 +493,46 @@ private extension DashboardPromptsCardCell { return } - bloggingPromptsService.todaysPrompt(success: { [weak self] prompt in - self?.prompt = prompt - self?.didFailLoadingPrompt = false - }, failure: { [weak self] error in - self?.prompt = nil - self?.didFailLoadingPrompt = true - DDLogError("Failed fetching blogging prompt: \(String(describing: error))") - }) + bloggingPromptsService.todaysPrompt( + success: { [weak self] prompt in + self?.prompt = prompt + self?.didFailLoadingPrompt = false + }, + failure: { [weak self] error in + self?.prompt = nil + self?.didFailLoadingPrompt = true + DDLogError("Failed fetching blogging prompt: \(String(describing: error))") + } + ) } // MARK: Button actions @objc func answerButtonTapped() { guard let blog, - let prompt else { + let prompt + else { return } WPAnalytics.track(.promptsDashboardCardAnswerPrompt) - let editor = EditPostViewController(blog: blog, prompt: prompt) - editor.modalPresentationStyle = .fullScreen - editor.entryPoint = .bloggingPromptsDashboardCard - presenterViewController?.present(editor, animated: true) + guard let presenterViewController else { return } + PostEditorRouter.showNewPost( + for: blog, + from: presenterViewController, + context: NewPostEditorContext( + prompt: prompt, + entryPoint: .bloggingPromptsDashboardCard + ) + ) } // MARK: Context menu actions func viewMoreMenuTapped() { guard let blog, - let presenterViewController else { + let presenterViewController + else { DDLogError("Failed showing Blogging Prompts from Dashboard card. Missing blog or controller.") return } @@ -519,7 +545,11 @@ private extension DashboardPromptsCardCell { WPAnalytics.track(.promptsDashboardCardMenuSkip) saveSkippedPromptForSite() presenterViewController?.reloadCardsLocally() - let notice = Notice(title: Strings.promptSkippedTitle, feedbackType: .success, actionTitle: Strings.undoSkipTitle) { [weak self] _ in + let notice = Notice( + title: Strings.promptSkippedTitle, + feedbackType: .success, + actionTitle: Strings.undoSkipTitle + ) { [weak self] _ in self?.clearSkippedPromptForSite() self?.presenterViewController?.reloadCardsLocally() } @@ -542,32 +572,62 @@ private extension DashboardPromptsCardCell { wpAssertionFailure("invalid_state") return } - BloggingPromptsIntroductionPresenter(interactionType: .actionable(blog: blog)).present(from: presenterViewController) + BloggingPromptsIntroductionPresenter(interactionType: .actionable(blog: blog)) + .present(from: presenterViewController) } // MARK: Constants struct Strings { - static let examplePrompt = NSLocalizedString("Cast the movie of your life.", comment: "Example prompt for the Prompts card in Feature Introduction.") - static let cardFrameTitle = NSLocalizedString("Prompts", comment: "Title label for the Prompts card in My Sites tab.") - static let answerButtonTitle = NSLocalizedString("Answer Prompt", comment: "Title for a call-to-action button on the prompts card.") - static let answeredLabelTitle = NSLocalizedString("✓ Answered", comment: "Title label that indicates the prompt has been answered.") - static let answerInfoSingularFormat = NSLocalizedString("%1$d answer", comment: "Singular format string for displaying the number of users " - + "that answered the blogging prompt.") - static let answerInfoPluralFormat = NSLocalizedString("%1$d answers", comment: "Plural format string for displaying the number of users " - + "that answered the blogging prompt.") - static let viewAllResponses = NSLocalizedString("prompts.card.viewprompts.title", - value: "View all responses", - comment: "Title for a tappable string that opens the reader with a prompts tag") - static let errorTitle = NSLocalizedString("Error loading prompt", comment: "Text displayed when there is a failure loading a blogging prompt.") - static let promptSkippedTitle = NSLocalizedString("Prompt skipped", comment: "Title of the notification presented when a prompt is skipped") - static let undoSkipTitle = NSLocalizedString("Undo", comment: "Button in the notification presented when a prompt is skipped") + static let examplePrompt = NSLocalizedString( + "Cast the movie of your life.", + comment: "Example prompt for the Prompts card in Feature Introduction." + ) + static let cardFrameTitle = NSLocalizedString( + "Prompts", + comment: "Title label for the Prompts card in My Sites tab." + ) + static let answerButtonTitle = NSLocalizedString( + "Answer Prompt", + comment: "Title for a call-to-action button on the prompts card." + ) + static let answeredLabelTitle = NSLocalizedString( + "✓ Answered", + comment: "Title label that indicates the prompt has been answered." + ) + static let answerInfoSingularFormat = NSLocalizedString( + "%1$d answer", + comment: "Singular format string for displaying the number of users " + + "that answered the blogging prompt." + ) + static let answerInfoPluralFormat = NSLocalizedString( + "%1$d answers", + comment: "Plural format string for displaying the number of users " + + "that answered the blogging prompt." + ) + static let viewAllResponses = NSLocalizedString( + "prompts.card.viewprompts.title", + value: "View all responses", + comment: "Title for a tappable string that opens the reader with a prompts tag" + ) + static let errorTitle = NSLocalizedString( + "Error loading prompt", + comment: "Text displayed when there is a failure loading a blogging prompt." + ) + static let promptSkippedTitle = NSLocalizedString( + "Prompt skipped", + comment: "Title of the notification presented when a prompt is skipped" + ) + static let undoSkipTitle = NSLocalizedString( + "Undo", + comment: "Button in the notification presented when a prompt is skipped" + ) } struct Style { static var avatarPlaceholderImage: UIImage { // this needs to be computed so the color is correct depending on the user interface style. - return UIImage(color: UIColor(light: .quaternarySystemFill, dark: .systemGray4)) + UIImage(color: UIColor(light: .quaternarySystemFill, dark: .systemGray4)) } } @@ -598,9 +658,15 @@ private extension DashboardPromptsCardCell { case .skip: return NSLocalizedString("Skip for today", comment: "Menu title to skip today's prompt.") case .remove: - return NSLocalizedString("Turn off prompts", comment: "Destructive menu title to remove the prompt card from the dashboard.") + return NSLocalizedString( + "Turn off prompts", + comment: "Destructive menu title to remove the prompt card from the dashboard." + ) case .learnMore: - return NSLocalizedString("Learn more", comment: "Menu title to show the prompts feature introduction modal.") + return NSLocalizedString( + "Learn more", + comment: "Menu title to show the prompts feature introduction modal." + ) } } @@ -629,9 +695,9 @@ private extension DashboardPromptsCardCell { var toAction: UIAction { switch self { case .viewMore(let handler), - .skip(let handler), - .remove(let handler), - .learnMore(let handler): + .skip(let handler), + .remove(let handler), + .learnMore(let handler): return UIAction(title: title, image: image, attributes: menuAttributes) { _ in handler() } @@ -645,12 +711,13 @@ private extension DashboardPromptsCardCell { private extension DashboardPromptsCardCell { static var allSkippedPrompts: [[String: Int32]] { - return UserPersistentStoreFactory.instance().array(forKey: Constants.skippedPromptsUDKey) as? [[String: Int32]] ?? [] + UserPersistentStoreFactory.instance().array(forKey: Constants.skippedPromptsUDKey) as? [[String: Int32]] ?? [] } func saveSkippedPromptForSite() { guard let prompt, - let siteID = blog?.dotComID?.stringValue else { + let siteID = blog?.dotComID?.stringValue + else { return } @@ -674,7 +741,8 @@ private extension DashboardPromptsCardCell { static func userSkippedPrompt(_ prompt: BloggingPrompt, for blog: Blog) -> Bool { guard FeatureFlag.bloggingPrompts.enabled, - let siteID = blog.dotComID?.stringValue else { + let siteID = blog.dotComID?.stringValue + else { return false } diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController+Swift.swift b/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController+Swift.swift index 391e97e0b5b5..ae2211b800e4 100644 --- a/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController+Swift.swift +++ b/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController+Swift.swift @@ -76,7 +76,7 @@ extension BlogDetailsViewController { public func showPostList(from source: BlogDetailsNavigationSource) { trackEvent(.openedPosts, from: source) - if blog.isSelfHosted, blog.isXMLRPCDisabled { + if blog.usesCustomPostTypeViewsForPostsAndPages { showPinnedPostType(.posts) return } @@ -89,7 +89,7 @@ extension BlogDetailsViewController { public func showPageList(from source: BlogDetailsNavigationSource) { trackEvent(.openedPages, from: source) - if blog.isSelfHosted, blog.isXMLRPCDisabled { + if blog.usesCustomPostTypeViewsForPostsAndPages { showPinnedPostType(.pages) return } diff --git a/WordPress/Classes/ViewRelated/Blog/Blogging Prompts/BloggingPromptCoordinator.swift b/WordPress/Classes/ViewRelated/Blog/Blogging Prompts/BloggingPromptCoordinator.swift index 6bff92074e42..2461eed59f27 100644 --- a/WordPress/Classes/ViewRelated/Blog/Blogging Prompts/BloggingPromptCoordinator.swift +++ b/WordPress/Classes/ViewRelated/Blog/Blogging Prompts/BloggingPromptCoordinator.swift @@ -49,22 +49,29 @@ import WordPressData /// - promptID: The ID of the blogging prompt. When nil, the method will use today's prompt. /// - blog: The blog associated with the blogging prompt. /// - completion: Closure invoked after the post creation flow is presented. - func showPromptAnsweringFlow(from viewController: UIViewController, - promptID: Int? = nil, - blog: Blog, - source: Source, - completion: (() -> Void)? = nil) { + func showPromptAnsweringFlow( + from viewController: UIViewController, + promptID: Int? = nil, + blog: Blog, + source: Source, + completion: (() -> Void)? = nil + ) { fetchPrompt(with: promptID, blog: blog) { result in guard case .success(let prompt) = result else { completion?() return } - // Present the post creation flow. - let editor = EditPostViewController(blog: blog, prompt: prompt) - editor.modalPresentationStyle = .fullScreen - editor.entryPoint = source.editorEntryPoint - viewController.present(editor, animated: true) + MainActor.assumeIsolated { + PostEditorRouter.showNewPost( + for: blog, + from: viewController, + context: NewPostEditorContext( + prompt: prompt, + entryPoint: source.editorEntryPoint + ) + ) + } completion?() } } @@ -76,16 +83,18 @@ import WordPressData /// - completion: Closure invoked after the scheduling process completes. func updatePromptsIfNeeded(for blog: Blog, completion: ((Result) -> Void)? = nil) { guard FeatureFlag.bloggingPrompts.enabled, - let service = self.promptsServiceFactory.makeService(for: blog) else { + let service = self.promptsServiceFactory.makeService(for: blog) + else { return } // fetch and update local prompts. service.fetchPrompts { [weak self] _ in // try to reschedule prompts if the user has any active reminders. - self?.reschedulePromptRemindersIfNeeded(for: blog) { - completion?(.success(())) - } + self? + .reschedulePromptRemindersIfNeeded(for: blog) { + completion?(.success(())) + } } failure: { error in completion?(.failure(error ?? Errors.unknown)) } @@ -114,37 +123,47 @@ private extension BloggingPromptCoordinator { } } - private func reschedulePromptRemindersIfNeeded(for blog: Blog, in context: NSManagedObjectContext, completion: @escaping () -> Void) { + private func reschedulePromptRemindersIfNeeded( + for blog: Blog, + in context: NSManagedObjectContext, + completion: @escaping () -> Void + ) { assert(blog.managedObjectContext == context) guard let settings = try? BloggingPromptSettings.of(blog), - let activeWeekdays = settings.reminderDays?.getActiveWeekdays(), - let reminderTimeDate = settings.reminderTimeDate(), - settings.promptRemindersEnabled + let activeWeekdays = settings.reminderDays?.getActiveWeekdays(), + let reminderTimeDate = settings.reminderTimeDate(), + settings.promptRemindersEnabled else { completion() return } // IMPORTANT: Ensure that push authorization is already granted before rescheduling. - UNUserNotificationCenter.current().getNotificationSettings { [weak self] notificationSettings in - guard let self, - notificationSettings.authorizationStatus == .authorized else { - completion() - return - } - - context.perform { - // Reschedule the prompt reminders. - let schedule = BloggingRemindersScheduler.Schedule.weekdays(activeWeekdays) - self.scheduler.schedule(schedule, for: blog, time: reminderTimeDate) { _ in + UNUserNotificationCenter.current() + .getNotificationSettings { [weak self] notificationSettings in + guard let self, + notificationSettings.authorizationStatus == .authorized + else { completion() + return + } + + context.perform { + // Reschedule the prompt reminders. + let schedule = BloggingRemindersScheduler.Schedule.weekdays(activeWeekdays) + self.scheduler.schedule(schedule, for: blog, time: reminderTimeDate) { _ in + completion() + } } } - } } - func fetchPrompt(with localPromptID: Int? = nil, blog: Blog, completion: @escaping (Result) -> Void) { + func fetchPrompt( + with localPromptID: Int? = nil, + blog: Blog, + completion: @escaping (Result) -> Void + ) { guard let service = promptsServiceFactory.makeService(for: blog) else { completion(.failure(Errors.invalidSite)) return @@ -152,7 +171,8 @@ private extension BloggingPromptCoordinator { // When the promptID is specified, there may be a cached prompt available. if let promptID = localPromptID, - let prompt = service.loadPrompt(with: promptID, in: blog) { + let prompt = service.loadPrompt(with: promptID, in: blog) + { completion(.success(prompt)) return } diff --git a/WordPress/Classes/ViewRelated/Blog/Blogging Prompts/BloggingPromptsViewController.swift b/WordPress/Classes/ViewRelated/Blog/Blogging Prompts/BloggingPromptsViewController.swift index 9bd7786af27e..17a1c6534010 100644 --- a/WordPress/Classes/ViewRelated/Blog/Blogging Prompts/BloggingPromptsViewController.swift +++ b/WordPress/Classes/ViewRelated/Blog/Blogging Prompts/BloggingPromptsViewController.swift @@ -18,7 +18,7 @@ class BloggingPromptsViewController: UIViewController, NoResultsViewHost { } private lazy var bloggingPromptsService: BloggingPromptsService? = { - return BloggingPromptsService(blog: blog) + BloggingPromptsService(blog: blog) }() private var isLoading: Bool = false { @@ -63,8 +63,10 @@ class BloggingPromptsViewController: UIViewController, NoResultsViewHost { private extension BloggingPromptsViewController { func configureTableView() { - tableView.register(BloggingPromptTableViewCell.defaultNib, - forCellReuseIdentifier: BloggingPromptTableViewCell.defaultReuseID) + tableView.register( + BloggingPromptTableViewCell.defaultNib, + forCellReuseIdentifier: BloggingPromptTableViewCell.defaultReuseID + ) tableView.accessibilityIdentifier = "Blogging Prompts List" tableView.allowsSelection = FeatureFlag.bloggingPrompts.enabled @@ -88,24 +90,30 @@ private extension BloggingPromptsViewController { func showNoResultsView() { hideNoResults() - configureAndDisplayNoResults(on: view, - title: NoResults.emptyTitle, - image: NoResults.imageName) + configureAndDisplayNoResults( + on: view, + title: NoResults.emptyTitle, + image: NoResults.imageName + ) } func showLoadingView() { hideNoResults() - configureAndDisplayNoResults(on: view, - title: NoResults.loadingTitle, - accessoryView: NoResultsViewController.loadingAccessoryView()) + configureAndDisplayNoResults( + on: view, + title: NoResults.loadingTitle, + accessoryView: NoResultsViewController.loadingAccessoryView() + ) } func showErrorView() { hideNoResults() - configureAndDisplayNoResults(on: view, - title: NoResults.errorTitle, - subtitle: NoResults.errorSubtitle, - image: NoResults.imageName) + configureAndDisplayNoResults( + on: view, + title: NoResults.errorTitle, + subtitle: NoResults.errorSubtitle, + image: NoResults.imageName + ) } func fetchPrompts() { @@ -117,14 +125,17 @@ private extension BloggingPromptsViewController { isLoading = true - bloggingPromptsService.fetchListPrompts(success: { [weak self] prompts in - self?.isLoading = false - self?.prompts = prompts.sorted(by: { $0.date.compare($1.date) == .orderedDescending }) - }, failure: { [weak self] error in - DDLogError("Failed fetching blogging prompts: \(String(describing: error))") - self?.isLoading = false - self?.showErrorView() - }) + bloggingPromptsService.fetchListPrompts( + success: { [weak self] prompts in + self?.isLoading = false + self?.prompts = prompts.sorted(by: { $0.date.compare($1.date) == .orderedDescending }) + }, + failure: { [weak self] error in + DDLogError("Failed fetching blogging prompts: \(String(describing: error))") + self?.isLoading = false + self?.showErrorView() + } + ) } enum Strings { @@ -132,10 +143,22 @@ private extension BloggingPromptsViewController { } enum NoResults { - static let loadingTitle = NSLocalizedString("Loading prompts...", comment: "Displayed while blogging prompts are being loaded.") - static let errorTitle = NSLocalizedString("Oops", comment: "Title for the view when there's an error loading blogging prompts.") - static let errorSubtitle = NSLocalizedString("There was an error loading prompts.", comment: "Text displayed when there is a failure loading blogging prompts.") - static let emptyTitle = NSLocalizedString("No prompts yet", comment: "Title displayed when there are no blogging prompts to display.") + static let loadingTitle = NSLocalizedString( + "Loading prompts...", + comment: "Displayed while blogging prompts are being loaded." + ) + static let errorTitle = NSLocalizedString( + "Oops", + comment: "Title for the view when there's an error loading blogging prompts." + ) + static let errorSubtitle = NSLocalizedString( + "There was an error loading prompts.", + comment: "Text displayed when there is a failure loading blogging prompts." + ) + static let emptyTitle = NSLocalizedString( + "No prompts yet", + comment: "Title displayed when there are no blogging prompts to display." + ) static let imageName = "wp-illustration-empty-results" } } @@ -145,12 +168,15 @@ private extension BloggingPromptsViewController { extension BloggingPromptsViewController: UITableViewDataSource, UITableViewDelegate { func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - return prompts.count + prompts.count } func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - guard let cell = tableView.dequeueReusableCell(withIdentifier: BloggingPromptTableViewCell.defaultReuseID) as? BloggingPromptTableViewCell, - let prompt = prompts[safe: indexPath.row] else { + guard + let cell = tableView.dequeueReusableCell(withIdentifier: BloggingPromptTableViewCell.defaultReuseID) + as? BloggingPromptTableViewCell, + let prompt = prompts[safe: indexPath.row] + else { return UITableViewCell() } @@ -161,16 +187,21 @@ extension BloggingPromptsViewController: UITableViewDataSource, UITableViewDeleg func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { tableView.deselectRow(at: indexPath, animated: true) guard FeatureFlag.bloggingPrompts.enabled, - let blog, - let cell = tableView.cellForRow(at: indexPath) as? BloggingPromptTableViewCell, - let prompt = cell.prompt else { + let blog, + let cell = tableView.cellForRow(at: indexPath) as? BloggingPromptTableViewCell, + let prompt = cell.prompt + else { return } - let editor = EditPostViewController(blog: blog, prompt: prompt) - editor.modalPresentationStyle = .fullScreen - editor.entryPoint = .bloggingPromptsListView - present(editor, animated: true) + PostEditorRouter.showNewPost( + for: blog, + from: self, + context: NewPostEditorContext( + prompt: prompt, + entryPoint: .bloggingPromptsListView + ) + ) } } @@ -194,7 +225,8 @@ private extension BloggingPromptsViewController { switch self { case .all: return NSLocalizedString("All", comment: "Title of all Blogging Prompts filter.") case .answered: return NSLocalizedString("Answered", comment: "Title of answered Blogging Prompts filter.") - case .notAnswered: return NSLocalizedString("Not Answered", comment: "Title of unanswered Blogging Prompts filter.") + case .notAnswered: + return NSLocalizedString("Not Answered", comment: "Title of unanswered Blogging Prompts filter.") } } } @@ -216,6 +248,6 @@ private extension BloggingPromptsViewController { extension BloggingPromptsViewController: StoryboardLoadable { static var defaultStoryboardName: String { - return "BloggingPromptsViewController" + "BloggingPromptsViewController" } } diff --git a/WordPress/Classes/ViewRelated/Blog/My Site/MySiteViewController+FAB.swift b/WordPress/Classes/ViewRelated/Blog/My Site/MySiteViewController+FAB.swift index 2030ed95fd6e..d88832172035 100644 --- a/WordPress/Classes/ViewRelated/Blog/My Site/MySiteViewController+FAB.swift +++ b/WordPress/Classes/ViewRelated/Blog/My Site/MySiteViewController+FAB.swift @@ -4,32 +4,20 @@ import WordPressData import WordPressShared extension MySiteViewController { - - static func shouldUseCoreRESTEditor(for blog: Blog?) -> Bool { - guard let blog else { return false } - return blog.isSelfHosted && blog.isXMLRPCDisabled - } - /// Make a create button coordinator with /// - Returns: CreateButtonCoordinator with new post, page, and story actions. @objc func makeCreateButtonCoordinator() -> CreateButtonCoordinator { - let newPage = { [weak self] in - let presenter = RootViewCoordinator.sharedPresenter - if let blog = self?.blog, Self.shouldUseCoreRESTEditor(for: blog) { - presenter.showCoreRESTPageEditor(blog: blog) - } else { - presenter.showPageEditor() - } + let newPage = { + RootViewCoordinator.sharedPresenter.showNewPageEditor() } - let newPost = { [weak self] in - let presenter = RootViewCoordinator.sharedPresenter - if let blog = self?.blog, Self.shouldUseCoreRESTEditor(for: blog) { - presenter.showCoreRESTPostEditor(blog: blog) - } else { - presenter.showPostEditor() - } + let newPost = { + RootViewCoordinator.sharedPresenter.showNewPostEditor( + context: NewPostEditorContext( + analytics: .editorCreatedPost(source: "create_button", postType: "post") + ) + ) } let source = "my_site" @@ -67,10 +55,14 @@ extension MySiteViewController { let viewModel = VoiceToContentViewModel(blog: blog) { [weak self] transcription in guard let self else { return } self.dismiss(animated: true) { - let presenter = RootViewCoordinator.sharedPresenter - let post = blog.createDraftPost() - post.voiceContent = transcription - presenter.showPostEditor(post: post) + PostEditorRouter.showNewPost( + for: blog, + from: self, + context: NewPostEditorContext( + voiceContent: transcription, + analytics: .editorCreatedPost(source: "create_button", postType: "post") + ) + ) } } let view = VoiceToContentView(viewModel: viewModel) diff --git a/WordPress/Classes/ViewRelated/CustomPostTypes/Blog+CustomPostTypes.swift b/WordPress/Classes/ViewRelated/CustomPostTypes/Blog+CustomPostTypes.swift new file mode 100644 index 000000000000..4aa3e0d4a71b --- /dev/null +++ b/WordPress/Classes/ViewRelated/CustomPostTypes/Blog+CustomPostTypes.swift @@ -0,0 +1,7 @@ +import WordPressData + +extension Blog { + var usesCustomPostTypeViewsForPostsAndPages: Bool { + isSelfHosted && isXMLRPCDisabled + } +} diff --git a/WordPress/Classes/ViewRelated/CustomPostTypes/CoreRESTPostEditorRoute.swift b/WordPress/Classes/ViewRelated/CustomPostTypes/CoreRESTPostEditorRoute.swift index 4bd7b6e11d1a..9eb3de5016c5 100644 --- a/WordPress/Classes/ViewRelated/CustomPostTypes/CoreRESTPostEditorRoute.swift +++ b/WordPress/Classes/ViewRelated/CustomPostTypes/CoreRESTPostEditorRoute.swift @@ -5,7 +5,9 @@ import WordPressData struct CoreRESTPostEditorRoute: View { let blog: Blog let postType: PinnedPostType + let initialSettings: PostSettings? let initialContent: EditorContent? + let entryPoint: PostEditorEntryPoint weak var presentingViewController: UIViewController? @Environment(\.dismiss) private var dismiss @@ -40,8 +42,9 @@ struct CoreRESTPostEditorRoute: View { post: nil, details: resolved.details, blog: blog, - initialSettings: nil, - initialContent: initialContent + initialSettings: initialSettings, + initialContent: initialContent, + entryPoint: entryPoint ) .toolbar(.hidden, for: .navigationBar) ) diff --git a/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostEditor.swift b/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostEditor.swift index 1f52e9d2fc26..9e68a72a347f 100644 --- a/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostEditor.swift +++ b/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostEditor.swift @@ -13,6 +13,7 @@ struct CustomPostEditor: View { let blog: Blog let initialSettings: PostSettings? let initialContent: EditorContent? + var entryPoint: PostEditorEntryPoint? = nil var body: some View { ViewControllerWrapper( @@ -22,7 +23,8 @@ struct CustomPostEditor: View { details: details, blog: blog, initialSettings: initialSettings, - initialContent: initialContent + initialContent: initialContent, + entryPoint: entryPoint ) .ignoresSafeArea() } @@ -36,6 +38,7 @@ private struct ViewControllerWrapper: UIViewControllerRepresentable { let blog: Blog let initialSettings: PostSettings? let initialContent: EditorContent? + let entryPoint: PostEditorEntryPoint? @Environment(\.dismiss) var dismiss: DismissAction @@ -48,7 +51,8 @@ private struct ViewControllerWrapper: UIViewControllerRepresentable { post: post, details: details, initialSettings: initialSettings, - initialContent: initialContent + initialContent: initialContent, + entryPoint: entryPoint ) { dismiss() } diff --git a/WordPress/Classes/ViewRelated/CustomPostTypes/PostEditorRouter.swift b/WordPress/Classes/ViewRelated/CustomPostTypes/PostEditorRouter.swift new file mode 100644 index 000000000000..cef6e11f25fe --- /dev/null +++ b/WordPress/Classes/ViewRelated/CustomPostTypes/PostEditorRouter.swift @@ -0,0 +1,274 @@ +import SwiftUI +import UIKit +import WordPressData +import WordPressShared + +struct NewPostEditorContext { + enum Analytics { + case editorCreatedPost(source: String, postType: String) + case editorCreatedPage(source: String) + case none + } + + var title: String? + var content: String? + var tags: String? + var prompt: BloggingPrompt? + var voiceContent: String? + var initialMedia: [Media] + var entryPoint: PostEditorEntryPoint + var analytics: Analytics + var animated: Bool + var afterDismiss: (() -> Void)? + + init( + title: String? = nil, + content: String? = nil, + tags: String? = nil, + prompt: BloggingPrompt? = nil, + voiceContent: String? = nil, + initialMedia: [Media] = [], + entryPoint: PostEditorEntryPoint = .unknown, + analytics: Analytics = .none, + animated: Bool = true, + afterDismiss: (() -> Void)? = nil + ) { + self.title = title + self.content = content + self.tags = tags + self.prompt = prompt + self.voiceContent = voiceContent + self.initialMedia = initialMedia + self.entryPoint = entryPoint + self.analytics = analytics + self.animated = animated + self.afterDismiss = afterDismiss + } + + func makeEditorContent() throws -> EditorContent { + let body = voiceContent ?? prompt?.editorContent ?? content ?? "" + let mediaBlocks = try initialMedia.map(Self.blockHTML(for:)) + let combined = ([body] + mediaBlocks) + .filter { !$0.isEmpty } + .joined(separator: "\n\n") + return EditorContent(title: title ?? "", content: combined) + } + + func makePostSettings(for blog: Blog) -> PostSettings { + var settings = PostSettings.defaults(from: blog) + let names = AbstractPost.makeTags(from: tags ?? "") + (prompt?.editorTags ?? []) + settings.tags = names.map { PostSettings.Term(id: 0, name: $0) } + settings.bloggingPromptID = prompt.map { String($0.promptID) } + return settings + } + + func trackAnalytics(for blog: Blog) { + analytics.track(blog: blog) + } + + func applyLegacyValues(to post: Post) { + post.postTitle = title + post.content = content + post.tags = tags + post.prepareForPrompt(prompt) + post.voiceContent = voiceContent + } + + private static func blockHTML(for media: Media) throws -> String { + guard + let id = media.mediaID?.intValue, + id > 0, + let remoteURL = media.remoteURL, + let validatedURL = URL(string: remoteURL), + let scheme = validatedURL.scheme?.lowercased(), + ["http", "https"].contains(scheme), + validatedURL.host?.isEmpty == false + else { + throw InitialContextError.invalidMedia + } + + let absoluteURL = validatedURL.absoluteString + let url = absoluteURL.escapeHtmlNamedEntities() + switch media.mediaType { + case .image: + let alt = (media.alt ?? "").escapeHtmlNamedEntities() + return """ + +
\(alt)
+ + """ + case .video: + return """ + +
+ + """ + case .audio: + return """ + +
+ + """ + case .document, .powerpoint: + let name = (media.filename ?? absoluteURL).escapeHtmlNamedEntities() + return """ + + + + """ + } + } + + private enum InitialContextError: LocalizedError { + case invalidMedia + + var errorDescription: String? { + NSLocalizedString( + "postEditorRouter.invalidMedia", + value: "The selected media could not be added.", + comment: "Error shown when creating a post from media without a usable remote URL." + ) + } + } +} + +@MainActor +enum PostEditorRouter { + enum Destination: Equatable { + case legacy + case coreREST + } + + static func destination(for blog: Blog) -> Destination { + blog.usesCustomPostTypeViewsForPostsAndPages ? .coreREST : .legacy + } + + static func showNewPost( + for blog: Blog, + from presenter: UIViewController, + context: NewPostEditorContext = .init() + ) { + context.trackAnalytics(for: blog) + switch destination(for: blog) { + case .coreREST: + presentCoreRESTEditor(blog: blog, postType: .posts, context: context, from: presenter) + case .legacy: + let post = blog.createDraftPost() + context.applyLegacyValues(to: post) + let editor = EditPostViewController(post: post) + editor.insertedMedia = context.initialMedia + editor.entryPoint = context.entryPoint + editor.showImmediately = !context.animated + editor.afterDismiss = context.afterDismiss + presenter.present(editor, animated: false) + } + } + + static func showNewPage( + for blog: Blog, + from presenter: UIViewController, + context: NewPostEditorContext = .init() + ) { + context.trackAnalytics(for: blog) + switch destination(for: blog) { + case .coreREST: + presentCoreRESTEditor(blog: blog, postType: .pages, context: context, from: presenter) + case .legacy: + let editor = EditPageViewController( + blog: blog, + postTitle: context.title, + content: context.content + ) + editor.entryPoint = context.entryPoint + editor.onClose = context.afterDismiss + presenter.present(editor, animated: false) + } + } + + private static func presentCoreRESTEditor( + blog: Blog, + postType: PinnedPostType, + context: NewPostEditorContext, + from presenter: UIViewController + ) { + let controller = CoreRESTPostEditorHostingController(onDismiss: context.afterDismiss) + let route: CoreRESTPostEditorRoute + do { + route = try makeCoreRESTRoute( + blog: blog, + postType: postType, + context: context, + presentingViewController: controller + ) + } catch { + Notice(error: error).post() + return + } + + controller.rootView = AnyView(route) + controller.modalPresentationStyle = .fullScreen + presenter.present(controller, animated: context.animated) + } + + static func makeCoreRESTRoute( + blog: Blog, + postType: PinnedPostType, + context: NewPostEditorContext, + presentingViewController: UIViewController + ) throws -> CoreRESTPostEditorRoute { + CoreRESTPostEditorRoute( + blog: blog, + postType: postType, + initialSettings: context.makePostSettings(for: blog), + initialContent: try context.makeEditorContent(), + entryPoint: context.entryPoint, + presentingViewController: presentingViewController + ) + } +} + +@MainActor +final class CoreRESTPostEditorHostingController: UIHostingController { + private var onDismiss: (() -> Void)? + + init(onDismiss: (() -> Void)?) { + self.onDismiss = onDismiss + super.init(rootView: AnyView(EmptyView())) + } + + @preconcurrency required dynamic init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + guard isBeingDismissed || presentingViewController == nil else { return } + let onDismiss = onDismiss + self.onDismiss = nil + onDismiss?() + } +} + +private extension NewPostEditorContext.Analytics { + func track(blog: Blog) { + switch self { + case let .editorCreatedPost(source, postType): + WPAppAnalytics.track( + .editorCreatedPost, + properties: [ + WPAppAnalyticsKeyTapSource: source, + WPAppAnalyticsKeyPostType: postType + ], + blog: blog + ) + case let .editorCreatedPage(source): + WPAnalytics.track( + .editorCreatedPage, + properties: [WPAppAnalyticsKeyTapSource: source], + blog: blog + ) + case .none: + break + } + } +} diff --git a/WordPress/Classes/ViewRelated/Feature Introduction/Blogging Prompts/BloggingPromptsIntroductionPresenter.swift b/WordPress/Classes/ViewRelated/Feature Introduction/Blogging Prompts/BloggingPromptsIntroductionPresenter.swift index 03b6d4718673..14123e8b16f3 100644 --- a/WordPress/Classes/ViewRelated/Feature Introduction/Blogging Prompts/BloggingPromptsIntroductionPresenter.swift +++ b/WordPress/Classes/ViewRelated/Feature Introduction/Blogging Prompts/BloggingPromptsIntroductionPresenter.swift @@ -38,7 +38,7 @@ class BloggingPromptsIntroductionPresenter: NSObject { }() private lazy var bloggingPromptsService: BloggingPromptsService? = { - return BloggingPromptsService(blog: blogToUse()) + BloggingPromptsService(blog: blogToUse()) }() // MARK: - Init @@ -81,7 +81,8 @@ private extension BloggingPromptsIntroductionPresenter { func showPostCreation() { guard let blog = blogToUse(), - let presentingViewController else { + let presentingViewController + else { wpAssertionFailure("invalid_state") navigationController.dismiss(animated: true) return @@ -94,38 +95,57 @@ private extension BloggingPromptsIntroductionPresenter { return } - let editor = EditPostViewController(blog: blog, prompt: prompt) - editor.modalPresentationStyle = .fullScreen - editor.entryPoint = .bloggingPromptsFeatureIntroduction - - self?.navigationController.dismiss(animated: true, completion: { [weak self] in - presentingViewController.present(editor, animated: false) - self?.trackPostEditorShown(blog) - }) + self?.navigationController + .dismiss( + animated: true, + completion: { [weak self] in + PostEditorRouter.showNewPost( + for: blog, + from: presentingViewController, + context: NewPostEditorContext( + prompt: prompt, + entryPoint: .bloggingPromptsFeatureIntroduction + ) + ) + self?.trackPostEditorShown(blog) + } + ) }) } func showRemindersScheduling() { guard let blog = blogToUse(), - let presentingViewController else { + let presentingViewController + else { wpAssertionFailure("invalid_state") navigationController.dismiss(animated: true) return } - navigationController.dismiss(animated: true, completion: { - BloggingRemindersFlow.present(from: presentingViewController, - for: blog, - source: .bloggingPromptsFeatureIntroduction) - }) + navigationController.dismiss( + animated: true, + completion: { + BloggingRemindersFlow.present( + from: presentingViewController, + for: blog, + source: .bloggingPromptsFeatureIntroduction + ) + } + ) } func blogToUse() -> Blog? { - return accountHasMultipleSites ? selectedBlog : accountSites?.first + accountHasMultipleSites ? selectedBlog : accountSites?.first } func trackPostEditorShown(_ blog: Blog) { - WPAppAnalytics.track(.editorCreatedPost, properties: [WPAppAnalyticsKeyTapSource: "blogging_prompts_feature_introduction", WPAppAnalyticsKeyPostType: "post"], blog: blog) + WPAppAnalytics.track( + .editorCreatedPost, + properties: [ + WPAppAnalyticsKeyTapSource: "blogging_prompts_feature_introduction", WPAppAnalyticsKeyPostType: "post" + ], + blog: blog + ) } // MARK: Prompt Fetching @@ -138,16 +158,22 @@ private extension BloggingPromptsIntroductionPresenter { return } - bloggingPromptsService.fetchTodaysPrompt(success: { prompt in - completion(prompt) - }, failure: { error in - completion(nil) - DDLogError("Feature Introduction: failed fetching blogging prompt: \(String(describing: error))") - }) + bloggingPromptsService.fetchTodaysPrompt( + success: { prompt in + completion(prompt) + }, + failure: { error in + completion(nil) + DDLogError("Feature Introduction: failed fetching blogging prompt: \(String(describing: error))") + } + ) } func dispatchErrorNotice() { - let message = NSLocalizedString("Error loading prompt", comment: "Text displayed when there is a failure loading a blogging prompt.") + let message = NSLocalizedString( + "Error loading prompt", + comment: "Text displayed when there is a failure loading a blogging prompt." + ) presentingViewController?.displayNotice(title: message) } } diff --git a/WordPress/Classes/ViewRelated/Media/MediaNoticeNavigationCoordinator.swift b/WordPress/Classes/ViewRelated/Media/MediaNoticeNavigationCoordinator.swift index c8687888d8c6..00680d2f5107 100644 --- a/WordPress/Classes/ViewRelated/Media/MediaNoticeNavigationCoordinator.swift +++ b/WordPress/Classes/ViewRelated/Media/MediaNoticeNavigationCoordinator.swift @@ -14,11 +14,16 @@ class MediaNoticeNavigationCoordinator { static func presentEditor(for blog: Blog, source: String, media: [Media]) { WPAppAnalytics.track(.notificationsUploadMediaSuccessWritePost, blog: blog) - let editor = EditPostViewController(blog: blog) - editor.modalPresentationStyle = .fullScreen - editor.insertedMedia = media - RootViewCoordinator.sharedPresenter.rootViewController.present(editor, animated: false) - WPAppAnalytics.track(.editorCreatedPost, properties: [WPAppAnalyticsKeyTapSource: source, WPAppAnalyticsKeyPostType: "post"], blog: blog) + MainActor.assumeIsolated { + PostEditorRouter.showNewPost( + for: blog, + from: RootViewCoordinator.sharedPresenter.rootViewController, + context: NewPostEditorContext( + initialMedia: media, + analytics: .editorCreatedPost(source: source, postType: "post") + ) + ) + } } static func navigateToMediaLibrary(with userInfo: NSDictionary) { @@ -39,19 +44,20 @@ class MediaNoticeNavigationCoordinator { let URIRepresentation = URL(string: blogID), let objectID = context.persistentStoreCoordinator?.managedObjectID(forURIRepresentation: URIRepresentation), let managedObject = try? context.existingObject(with: objectID), - let blog = managedObject as? Blog else { - return nil + let blog = managedObject as? Blog + else { + return nil } return blog } private static func successfulMedia(from userInfo: NSDictionary) -> [Media] { - return media(from: userInfo, withKey: MediaNoticeUserInfoKey.mediaIDs) + media(from: userInfo, withKey: MediaNoticeUserInfoKey.mediaIDs) } private static func failedMedia(from userInfo: NSDictionary) -> [Media] { - return media(from: userInfo, withKey: MediaNoticeUserInfoKey.failedMediaIDs) + media(from: userInfo, withKey: MediaNoticeUserInfoKey.failedMediaIDs) } private static func media(from userInfo: NSDictionary, withKey key: String) -> [Media] { @@ -60,9 +66,11 @@ class MediaNoticeNavigationCoordinator { if let mediaIDs = userInfo[key] as? [String] { let media = mediaIDs.compactMap({ mediaID -> Media? in if let URIRepresentation = URL(string: mediaID), - let objectID = context.persistentStoreCoordinator?.managedObjectID(forURIRepresentation: URIRepresentation), + let objectID = context.persistentStoreCoordinator? + .managedObjectID(forURIRepresentation: URIRepresentation), let managedObject = try? context.existingObject(with: objectID), - let media = managedObject as? Media { + let media = managedObject as? Media + { return media } diff --git a/WordPress/Classes/ViewRelated/NewGutenberg/CustomPostEditorViewController.swift b/WordPress/Classes/ViewRelated/NewGutenberg/CustomPostEditorViewController.swift index 0e5b1d209c9c..824eb8d0ca74 100644 --- a/WordPress/Classes/ViewRelated/NewGutenberg/CustomPostEditorViewController.swift +++ b/WordPress/Classes/ViewRelated/NewGutenberg/CustomPostEditorViewController.swift @@ -16,6 +16,7 @@ class CustomPostEditorViewController: PostGBKEditorViewController { let details: PostTypeDetailsWithEditContext let completion: () -> Void let editorService: CustomPostEditorService + private var editorSession: PostEditorAnalyticsSession? private lazy var primarySaveButton = UIBarButtonItem(primaryAction: savePostAction()) private lazy var redoButton = UIBarButtonItem( @@ -39,11 +40,21 @@ class CustomPostEditorViewController: PostGBKEditorViewController { details: PostTypeDetailsWithEditContext, initialSettings: PostSettings? = nil, initialContent: EditorContent? = nil, + entryPoint: PostEditorEntryPoint? = nil, completion: @escaping () -> Void ) { self.client = client self.details = details self.completion = completion + self.editorSession = entryPoint.map { + PostEditorAnalyticsSession( + editor: .gutenbergKit, + blog: blog, + postType: details.slug, + contentType: .init(content: initialContent?.content), + entryPoint: $0 + ) + } self.editorService = CustomPostEditorService( blog: blog, @@ -88,6 +99,12 @@ class CustomPostEditorViewController: PostGBKEditorViewController { undoButton.isEnabled = false } + override func editorDidLoad(_ viewContoller: GutenbergKit.EditorViewController) { + if editorSession?.started == false { + editorSession?.start() + } + } + override func editor( _ viewController: GutenbergKit.EditorViewController, didUpdateHistoryState state: EditorState @@ -134,7 +151,9 @@ private extension CustomPostEditorViewController { let alert = UIAlertController(title: nil, message: nil, preferredStyle: .actionSheet) alert.addCancelActionWithTitle(PostEditorStrings.keepEditing) alert.addDestructiveActionWithTitle(PostEditorStrings.discardChanges) { [weak self] _ in - self?.navigationController?.dismiss(animated: true) + guard let self else { return } + self.endEditorSession(outcome: .discard) + self.navigationController?.dismiss(animated: true) } if post?.status ?? .draft == .draft { alert.addAction( @@ -153,7 +172,8 @@ private extension CustomPostEditorViewController { alert.popoverPresentationController?.barButtonItem = self.navigationItem.leftBarButtonItem self.present(alert, animated: true) } else { - self.navigationController?.dismiss(animated: true) + endEditorSession(outcome: .cancel) + navigationController?.dismiss(animated: true) } } @@ -229,6 +249,7 @@ private extension CustomPostEditorViewController { from: self, completion: { [weak self] result in if case .published = result { + self?.endEditorSession(outcome: .publish) self?.completion() } } @@ -238,6 +259,12 @@ private extension CustomPostEditorViewController { private func separator() -> UIBarButtonItem { UIBarButtonItem(systemItem: .fixedSpace) } + + func endEditorSession(outcome: PostEditorAnalyticsSession.Outcome) { + guard let editorSession, editorSession.started else { return } + editorSession.end(outcome: outcome) + self.editorSession = nil + } } // MARK: - Update post diff --git a/WordPress/Classes/ViewRelated/Notifications/Controllers/NotificationsViewController/NotificationsViewController.swift b/WordPress/Classes/ViewRelated/Notifications/Controllers/NotificationsViewController/NotificationsViewController.swift index 913324145bf6..4f2e75ad4b5f 100644 --- a/WordPress/Classes/ViewRelated/Notifications/Controllers/NotificationsViewController/NotificationsViewController.swift +++ b/WordPress/Classes/ViewRelated/Notifications/Controllers/NotificationsViewController/NotificationsViewController.swift @@ -94,7 +94,7 @@ class NotificationsViewController: UIViewController, UITableViewDataSource, UITa private var shouldCancelNextUpdateAnimation = false private lazy var notificationCommentDetailCoordinator: NotificationCommentDetailCoordinator = { - return NotificationCommentDetailCoordinator(notificationsNavigationDataSource: self) + NotificationCommentDetailCoordinator(notificationsNavigationDataSource: self) }() /// Activity Indicator to be shown when refreshing a Jetpack site status. @@ -121,7 +121,10 @@ class NotificationsViewController: UIViewController, UITableViewDataSource, UITa // MARK: - View Lifecycle - static func showInPopover(from presentingVC: UIViewController, sourceItem: UIPopoverPresentationControllerSourceItem) { + static func showInPopover( + from presentingVC: UIViewController, + sourceItem: UIPopoverPresentationControllerSourceItem + ) { let notificationsVC = Notifications.instantiateInitialViewController() notificationsVC.isSidebarModeEnabled = true @@ -285,7 +288,9 @@ class NotificationsViewController: UIViewController, UITableViewDataSource, UITa func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { guard let cell = tableView.dequeueReusableCell(withIdentifier: TableViewCell.reuseIdentifier) as? TableViewCell, - let note = tableViewHandler?.resultsController?.managedObject(atUnsafe: indexPath) as? WordPressData.Notification else { + let note = tableViewHandler?.resultsController?.managedObject(atUnsafe: indexPath) + as? WordPressData.Notification + else { return UITableViewCell() } cell.selectionStyle = splitViewControllerIsHorizontallyCompact ? .none : .default @@ -302,18 +307,23 @@ class NotificationsViewController: UIViewController, UITableViewDataSource, UITa } func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool { - return true + true } - func tableView(_ tableView: UITableView, editingStyleForRowAt indexPath: IndexPath) -> UITableViewCell.EditingStyle { - return .none + func tableView( + _ tableView: UITableView, + editingStyleForRowAt indexPath: IndexPath + ) -> UITableViewCell.EditingStyle { + .none } // MARK: - UITableViewDelegate Methods func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { guard let sectionInfo = tableViewHandler?.resultsController?.sections?[section], - let view = tableView.dequeueReusableHeaderFooterView(withIdentifier: NotificationsTableHeaderView.reuseIdentifier) as? NotificationsTableHeaderView + let view = tableView.dequeueReusableHeaderFooterView( + withIdentifier: NotificationsTableHeaderView.reuseIdentifier + ) as? NotificationsTableHeaderView else { return nil } @@ -323,12 +333,12 @@ class NotificationsViewController: UIViewController, UITableViewDataSource, UITa func tableView(_ tableView: UITableView, heightForFooterInSection section: Int) -> CGFloat { // Make sure no SectionFooter is rendered - return CGFloat.leastNormalMagnitude + CGFloat.leastNormalMagnitude } func tableView(_ tableView: UITableView, viewForFooterInSection section: Int) -> UIView? { // Make sure no SectionFooter is rendered - return nil + nil } func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { @@ -343,12 +353,15 @@ class NotificationsViewController: UIViewController, UITableViewDataSource, UITa } func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { - return UITableView.automaticDimension + UITableView.automaticDimension } func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { // Failsafe: Make sure that the Notification (still) exists - guard let note = tableViewHandler?.resultsController?.managedObject(atUnsafe: indexPath) as? WordPressData.Notification else { + guard + let note = tableViewHandler?.resultsController?.managedObject(atUnsafe: indexPath) + as? WordPressData.Notification + else { tableView.deselectSelectedRowWithAnimation(true) return } @@ -369,56 +382,83 @@ class NotificationsViewController: UIViewController, UITableViewDataSource, UITa } } - func tableView(_ tableView: UITableView, leadingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? { + func tableView( + _ tableView: UITableView, + leadingSwipeActionsConfigurationForRowAt indexPath: IndexPath + ) -> UISwipeActionsConfiguration? { // skip when the notification is marked for deletion. - guard let note = tableViewHandler?.resultsController?.managedObject(atUnsafe: indexPath) as? WordPressData.Notification, - deletionRequestForNoteWithID(note.objectID) == nil else { + guard + let note = tableViewHandler?.resultsController?.managedObject(atUnsafe: indexPath) + as? WordPressData.Notification, + deletionRequestForNoteWithID(note.objectID) == nil + else { return nil } let isRead = note.read - let title = isRead ? NSLocalizedString("Mark Unread", comment: "Marks a notification as unread") : - NSLocalizedString("Mark Read", comment: "Marks a notification as unread") - - let action = UIContextualAction(style: .normal, title: title, handler: { _, _, completionHandler in - if isRead { - WPAnalytics.track(.notificationMarkAsUnreadTapped) - self.markAsUnread(note: note) - } else { - WPAnalytics.track(.notificationMarkAsReadTapped) - self.markAsRead(note: note) + let title = + isRead + ? NSLocalizedString("Mark Unread", comment: "Marks a notification as unread") + : NSLocalizedString("Mark Read", comment: "Marks a notification as unread") + + let action = UIContextualAction( + style: .normal, + title: title, + handler: { _, _, completionHandler in + if isRead { + WPAnalytics.track(.notificationMarkAsUnreadTapped) + self.markAsUnread(note: note) + } else { + WPAnalytics.track(.notificationMarkAsReadTapped) + self.markAsRead(note: note) + } + completionHandler(true) } - completionHandler(true) - }) + ) action.backgroundColor = UIAppColor.neutral(.shade50) return UISwipeActionsConfiguration(actions: [action]) } - func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? { + func tableView( + _ tableView: UITableView, + trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath + ) -> UISwipeActionsConfiguration? { // skip when the notification is marked for deletion. - guard let note = tableViewHandler?.resultsController?.managedObject(atUnsafe: indexPath) as? WordPressData.Notification, + guard + let note = tableViewHandler?.resultsController?.managedObject(atUnsafe: indexPath) + as? WordPressData.Notification, let block: FormattableCommentContent = note.contentGroup(ofKind: .comment)?.blockOfKind(.comment), - deletionRequestForNoteWithID(note.objectID) == nil else { + deletionRequestForNoteWithID(note.objectID) == nil + else { return nil } // Approve comment guard let approveEnabled = block.action(id: ApproveCommentAction.actionIdentifier())?.enabled, - approveEnabled == true, - let approveAction = block.action(id: ApproveCommentAction.actionIdentifier()), - let actionTitle = approveAction.command?.actionTitle else { + approveEnabled == true, + let approveAction = block.action(id: ApproveCommentAction.actionIdentifier()), + let actionTitle = approveAction.command?.actionTitle + else { return nil } - let action = UIContextualAction(style: .normal, title: actionTitle, handler: { _, _, completionHandler in - WPAppAnalytics.track(approveAction.on ? .notificationsCommentUnapproved : .notificationsCommentApproved, properties: [Stats.sourceKey: Stats.sourceValue], blogID: block.metaSiteID) + let action = UIContextualAction( + style: .normal, + title: actionTitle, + handler: { _, _, completionHandler in + WPAppAnalytics.track( + approveAction.on ? .notificationsCommentUnapproved : .notificationsCommentApproved, + properties: [Stats.sourceKey: Stats.sourceValue], + blogID: block.metaSiteID + ) - let actionContext = ActionContext(block: block) - approveAction.execute(context: actionContext) - completionHandler(true) - }) + let actionContext = ActionContext(block: block) + approveAction.execute(context: actionContext) + completionHandler(true) + } + ) action.backgroundColor = approveAction.command?.actionColor let configuration = UISwipeActionsConfiguration(actions: [action]) @@ -426,7 +466,10 @@ class NotificationsViewController: UIViewController, UITableViewDataSource, UITa return configuration } - fileprivate func configureDetailsViewController(_ detailsViewController: NotificationDetailsViewController, withNote note: WordPressData.Notification) { + fileprivate func configureDetailsViewController( + _ detailsViewController: NotificationDetailsViewController, + withNote note: WordPressData.Notification + ) { detailsViewController.navigationItem.largeTitleDisplayMode = .never detailsViewController.hidesBottomBarWhenPushed = true detailsViewController.dataSource = self @@ -483,7 +526,8 @@ private extension NotificationsViewController { func makeMoreMenuElements() -> [UIAction] { // Mark All As Read let markAllAsRead: UIAction? = { () -> UIAction? in - guard let notes = tableViewHandler?.resultsController?.fetchedObjects as? [WordPressData.Notification] else { + guard let notes = tableViewHandler?.resultsController?.fetchedObjects as? [WordPressData.Notification] + else { return nil } let isEnabled = notes.contains { !$0.read } @@ -538,7 +582,10 @@ private extension NotificationsViewController { func setupTableView() { // Register the cells - tableView.register(NotificationsTableHeaderView.self, forHeaderFooterViewReuseIdentifier: NotificationsTableHeaderView.reuseIdentifier) + tableView.register( + NotificationsTableHeaderView.self, + forHeaderFooterViewReuseIdentifier: NotificationsTableHeaderView.reuseIdentifier + ) tableView.register(TableViewCell.self, forCellReuseIdentifier: TableViewCell.reuseIdentifier) // UITableView @@ -612,39 +659,67 @@ extension NotificationsViewController { private extension NotificationsViewController { func startListeningToNotifications() { let nc = NotificationCenter.default - nc.addObserver(self, selector: #selector(applicationDidBecomeActive), name: UIApplication.didBecomeActiveNotification, object: nil) - nc.addObserver(self, selector: #selector(notificationsWereUpdated), name: NSNotification.Name(rawValue: NotificationSyncMediatorDidUpdateNotifications), object: nil) - nc.addObserver(self, selector: #selector(dynamicTypeDidChange), name: UIContentSizeCategory.didChangeNotification, object: nil) + nc.addObserver( + self, + selector: #selector(applicationDidBecomeActive), + name: UIApplication.didBecomeActiveNotification, + object: nil + ) + nc.addObserver( + self, + selector: #selector(notificationsWereUpdated), + name: NSNotification.Name(rawValue: NotificationSyncMediatorDidUpdateNotifications), + object: nil + ) + nc.addObserver( + self, + selector: #selector(dynamicTypeDidChange), + name: UIContentSizeCategory.didChangeNotification, + object: nil + ) } func startListeningToAccountNotifications() { let nc = NotificationCenter.default - nc.addObserver(self, selector: #selector(defaultAccountDidChange), name: .wpAccountDefaultWordPressComAccountChanged, object: nil) + nc.addObserver( + self, + selector: #selector(defaultAccountDidChange), + name: .wpAccountDefaultWordPressComAccountChanged, + object: nil + ) } func startListeningToTimeChangeNotifications() { let nc = NotificationCenter.default - nc.addObserver(self, - selector: #selector(significantTimeChange), - name: UIApplication.significantTimeChangeNotification, - object: nil) + nc.addObserver( + self, + selector: #selector(significantTimeChange), + name: UIApplication.significantTimeChangeNotification, + object: nil + ) } func startListeningToCommentDeletedNotifications() { - NotificationCenter.default.addObserver(self, - selector: #selector(removeDeletedNotification), - name: .NotificationCommentDeletedNotification, - object: nil) + NotificationCenter.default.addObserver( + self, + selector: #selector(removeDeletedNotification), + name: .NotificationCommentDeletedNotification, + object: nil + ) } func stopListeningToNotifications() { let nc = NotificationCenter.default - nc.removeObserver(self, - name: UIApplication.didBecomeActiveNotification, - object: nil) - nc.removeObserver(self, - name: NSNotification.Name(rawValue: NotificationSyncMediatorDidUpdateNotifications), - object: nil) + nc.removeObserver( + self, + name: UIApplication.didBecomeActiveNotification, + object: nil + ) + nc.removeObserver( + self, + name: NSNotification.Name(rawValue: NotificationSyncMediatorDidUpdateNotifications), + object: nil + ) } @objc func applicationDidBecomeActive(_ note: Foundation.Notification) { @@ -684,15 +759,17 @@ private extension NotificationsViewController { needsReloadResults = true if UIApplication.shared.applicationState == .active && isViewLoaded == true - && view.window != nil { + && view.window != nil + { reloadResultsControllerIfNeeded() } } @objc func dynamicTypeDidChange() { - tableViewHandler?.resultsController?.fetchedObjects?.forEach { - ($0 as? WordPressData.Notification)?.resetCachedAttributes() - } + tableViewHandler?.resultsController?.fetchedObjects? + .forEach { + ($0 as? WordPressData.Notification)?.resetCachedAttributes() + } } } @@ -770,11 +847,14 @@ extension NotificationsViewController { // if let postID = note.metaPostID, let siteID = note.metaSiteID, - note.kind == .matcher || note.kind == .newPost { + note.kind == .matcher || note.kind == .newPost + { if isSidebarModeEnabled && splitViewController == nil { presentingViewController?.dismiss(animated: true) - RootViewCoordinator.sharedPresenter.showReader(path: .post(postID: postID.intValue, siteID: siteID.intValue)) + RootViewCoordinator.sharedPresenter.showReader( + path: .post(postID: postID.intValue, siteID: siteID.intValue) + ) } else { let readerViewController = ReaderDetailViewController.controllerWithPostID(postID, siteID: siteID) readerViewController.navigationItem.largeTitleDisplayMode = .never @@ -815,8 +895,12 @@ extension NotificationsViewController { } } - private func getNotificationCommentDetailViewController(for note: WordPressData.Notification) -> NotificationCommentDetailViewController? { - guard let commentDetailViewController = self.notificationCommentDetailCoordinator.createViewController(with: note) else { + private func getNotificationCommentDetailViewController( + for note: WordPressData.Notification + ) -> NotificationCommentDetailViewController? { + guard + let commentDetailViewController = self.notificationCommentDetailCoordinator.createViewController(with: note) + else { DDLogError("Notifications: failed creating Comment Detail view.") return nil } @@ -829,7 +913,9 @@ extension NotificationsViewController { return commentDetailViewController } - private func getNotificationDetailsViewController(for note: WordPressData.Notification) -> NotificationDetailsViewController? { + private func getNotificationDetailsViewController( + for note: WordPressData.Notification + ) -> NotificationDetailsViewController? { let viewControllerID = NotificationDetailsViewController.classNameWithoutNamespaces() let detailsViewController = storyboard?.instantiateViewController(withIdentifier: viewControllerID) guard let detailsViewController = detailsViewController as? NotificationDetailsViewController else { @@ -880,9 +966,11 @@ extension NotificationsViewController { return } - let noteIndexPath = tableView.indexPathsForVisibleRows?.first { indexPath in - return note == tableViewHandler?.resultsController?.managedObject(atUnsafe: indexPath) as? WordPressData.Notification - } + let noteIndexPath = tableView.indexPathsForVisibleRows? + .first { indexPath in + note == tableViewHandler?.resultsController?.managedObject(atUnsafe: indexPath) + as? WordPressData.Notification + } guard noteIndexPath == nil else { return @@ -912,7 +1000,11 @@ extension NotificationsViewController { /// @objc func showNotificationSettings() { let controller = NotificationSettingsViewController() - controller.navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .done, target: self, action: #selector(closeNotificationSettings)) + controller.navigationItem.rightBarButtonItem = UIBarButtonItem( + barButtonSystemItem: .done, + target: self, + action: #selector(closeNotificationSettings) + ) let navigationController = UINavigationController(rootViewController: controller) navigationController.modalPresentationStyle = .formSheet @@ -957,11 +1049,15 @@ private extension NotificationsViewController { notificationDeletionRequests.removeValue(forKey: noteObjectID) reloadRowForNotificationWithID(noteObjectID) - NSObject.cancelPreviousPerformRequests(withTarget: self, selector: #selector(deleteNoteWithID), object: noteObjectID) + NSObject.cancelPreviousPerformRequests( + withTarget: self, + selector: #selector(deleteNoteWithID), + object: noteObjectID + ) } func deletionRequestForNoteWithID(_ noteObjectID: NSManagedObjectID) -> NotificationDeletionRequest? { - return notificationDeletionRequests[noteObjectID] + notificationDeletionRequests[noteObjectID] } // MARK: - Notifications Deletion from CommentDetailViewController @@ -974,7 +1070,7 @@ private extension NotificationsViewController { selectNextAvailableNotification(ignoring: notificationCommentDetailCoordinator.notificationsCommentModerated) notificationCommentDetailCoordinator.notificationsCommentModerated.forEach { - syncNotification(with: $0.notificationId, timeout: Syncing.pushMaxWait, success: {_ in }) + syncNotification(with: $0.notificationId, timeout: Syncing.pushMaxWait, success: { _ in }) } notificationCommentDetailCoordinator.notificationsCommentModerated = [] @@ -982,10 +1078,11 @@ private extension NotificationsViewController { @objc func removeDeletedNotification(notification: NSNotification) { guard let userInfo = notification.userInfo, - let deletedCommentID = userInfo[userInfoCommentIdKey] as? Int32, - let notifications = tableViewHandler?.resultsController?.fetchedObjects as? [WordPressData.Notification] else { - return - } + let deletedCommentID = userInfo[userInfoCommentIdKey] as? Int32, + let notifications = tableViewHandler?.resultsController?.fetchedObjects as? [WordPressData.Notification] + else { + return + } let notification = notifications.first(where: { notification -> Bool in guard let commentID = notification.metaCommentID else { @@ -1005,24 +1102,33 @@ private extension NotificationsViewController { selectNextAvailableNotification(ignoring: [notification]) - syncNotification(with: notification.notificationId, timeout: Syncing.pushMaxWait, success: { [weak self] notification in - self?.notificationCommentDetailCoordinator.notificationsCommentModerated.removeAll(where: { $0.notificationId == notification.notificationId }) - }) + syncNotification( + with: notification.notificationId, + timeout: Syncing.pushMaxWait, + success: { [weak self] notification in + self?.notificationCommentDetailCoordinator.notificationsCommentModerated + .removeAll(where: { $0.notificationId == notification.notificationId }) + } + ) } func selectNextAvailableNotification(ignoring: [WordPressData.Notification]) { // If the currently selected notification is about to be removed, find the next available and select it. // This is only necessary for split view to prevent the details from showing for removed notifications. if !splitViewControllerIsHorizontallyCompact, - let selectedNotification, - ignoring.contains(selectedNotification) { - - guard let notifications = tableViewHandler?.resultsController?.fetchedObjects as? [WordPressData.Notification], - let nextAvailable = notifications.first(where: { !ignoring.contains($0) }), - let indexPath = tableViewHandler?.resultsController?.indexPath(forObject: nextAvailable) else { - self.selectedNotification = nil - return - } + let selectedNotification, + ignoring.contains(selectedNotification) + { + + guard + let notifications = tableViewHandler?.resultsController?.fetchedObjects + as? [WordPressData.Notification], + let nextAvailable = notifications.first(where: { !ignoring.contains($0) }), + let indexPath = tableViewHandler?.resultsController?.indexPath(forObject: nextAvailable) + else { + self.selectedNotification = nil + return + } self.selectedNotification = nextAvailable tableView(tableView, didSelectRowAt: indexPath) @@ -1074,12 +1180,17 @@ private extension NotificationsViewController { !$0.read } - NotificationSyncMediator()?.markAsRead(unreadNotifications, completion: { error in - let notice = Notice( - title: error != nil ? Localization.markAllAsReadNoticeFailure : Localization.markAllAsReadNoticeSuccess + NotificationSyncMediator()? + .markAsRead( + unreadNotifications, + completion: { error in + let notice = Notice( + title: error != nil + ? Localization.markAllAsReadNoticeFailure : Localization.markAllAsReadNoticeSuccess + ) + ActionDispatcherFacade().dispatch(NoticeAction.post(notice)) + } ) - ActionDispatcherFacade().dispatch(NoticeAction.post(notice)) - }) } /// Presents a confirmation action sheet for mark all as read action. @@ -1096,7 +1207,8 @@ private extension NotificationsViewController { default: title = NSLocalizedString( "Mark all %1$@ notifications as read?", - comment: "Confirmation title for marking all notifications under a filter as read. %1$@ is replaced by the filter name." + comment: + "Confirmation title for marking all notifications under a filter as read. %1$@ is replaced by the filter name." ) } @@ -1148,9 +1260,10 @@ private extension NotificationsViewController { // This is additive because we don't want to remove anything // from the list unless we explicitly call // clearUnreadNotifications() - notes.lazy.filter({ !$0.read }).forEach { note in - unreadNotificationIds.insert(note.objectID) - } + notes.lazy.filter({ !$0.read }) + .forEach { note in + unreadNotificationIds.insert(note.objectID) + } if previous != unreadNotificationIds && reloadingResultsController { reloadResultsController() } @@ -1213,20 +1326,26 @@ private extension NotificationsViewController { } } - func selectRow(for notification: WordPressData.Notification, animated: Bool = true, - scrollPosition: UITableView.ScrollPosition = .none) { + func selectRow( + for notification: WordPressData.Notification, + animated: Bool = true, + scrollPosition: UITableView.ScrollPosition = .none + ) { selectedNotification = notification // also ensure that the index path returned from results controller does not have negative row index. // ref: https://github.com/wordpress-mobile/WordPress-iOS/issues/15370 guard let indexPath = tableViewHandler?.resultsController?.indexPath(forObject: notification), - indexPath != tableView.indexPathForSelectedRow, - 0.. Bool { - return tableViewHandler?.resultsController?.isEmpty() ?? true + tableViewHandler?.resultsController?.isEmpty() ?? true } func noConnectionMessage() -> String { - return NSLocalizedString("No internet connection. Some content may be unavailable while offline.", - comment: "Error message shown when the user is browsing Notifications without an internet connection.") + NSLocalizedString( + "No internet connection. Some content may be unavailable while offline.", + comment: "Error message shown when the user is browsing Notifications without an internet connection." + ) } } @@ -1308,8 +1429,10 @@ extension NotificationsViewController { } // If we don't currently have a selected notification and there is a notification in the list, then select it. - if let firstNotification = tableViewHandler?.resultsController?.fetchedObjects?.first as? WordPressData.Notification, - let indexPath = tableViewHandler?.resultsController?.indexPath(forObject: firstNotification) { + if let firstNotification = tableViewHandler?.resultsController?.fetchedObjects?.first + as? WordPressData.Notification, + let indexPath = tableViewHandler?.resultsController?.indexPath(forObject: firstNotification) + { selectRow(for: firstNotification, animated: false, scrollPosition: .none) self.tableView(tableView, didSelectRowAt: indexPath) return @@ -1329,7 +1452,7 @@ extension NotificationsViewController { // extension NotificationsViewController: WPTableViewHandlerDelegate { func managedObjectContext() -> NSManagedObjectContext { - return ContextManager.shared.mainContext + ContextManager.shared.mainContext } func fetchRequest() -> NSFetchRequest? { @@ -1360,8 +1483,11 @@ extension NotificationsViewController: WPTableViewHandlerDelegate { } func configureCell(_ cell: UITableViewCell, at indexPath: IndexPath) { - guard let note = tableViewHandler?.resultsController?.managedObject(atUnsafe: indexPath) as? WordPressData.Notification, - let cell = cell as? ListTableViewCell else { + guard + let note = tableViewHandler?.resultsController?.managedObject(atUnsafe: indexPath) + as? WordPressData.Notification, + let cell = cell as? ListTableViewCell + else { return } @@ -1378,24 +1504,26 @@ extension NotificationsViewController: WPTableViewHandlerDelegate { } func sectionNameKeyPath() -> String { - return "sectionIdentifier" + "sectionIdentifier" } @objc func entityName() -> String { - return Notification.classNameWithoutNamespaces() + Notification.classNameWithoutNamespaces() } private var shouldCountNotificationsForSecondAlert: Bool { - userDefaults.notificationPrimerInlineWasAcknowledged && - userDefaults.secondNotificationsAlertCount != Constants.secondNotificationsAlertDisabled + userDefaults.notificationPrimerInlineWasAcknowledged + && userDefaults.secondNotificationsAlertCount != Constants.secondNotificationsAlertDisabled } func tableViewWillChangeContent(_ tableView: UITableView) { guard shouldCountNotificationsForSecondAlert, - let notification = tableViewHandler?.resultsController?.fetchedObjects?.first as? WordPressData.Notification, - let timestamp = notification.timestamp else { - timestampBeforeUpdatesForSecondAlert = nil - return + let notification = tableViewHandler?.resultsController?.fetchedObjects?.first + as? WordPressData.Notification, + let timestamp = notification.timestamp + else { + timestampBeforeUpdatesForSecondAlert = nil + return } timestampBeforeUpdatesForSecondAlert = timestamp @@ -1429,14 +1557,15 @@ extension NotificationsViewController: WPTableViewHandlerDelegate { } func shouldCancelUpdateAnimation() -> Bool { - return shouldCancelNextUpdateAnimation + shouldCancelNextUpdateAnimation } // counts the new notifications for the second alert private var newNotificationsForSecondAlert: Int { guard let previousTimestamp = timestampBeforeUpdatesForSecondAlert, - let notifications = tableViewHandler?.resultsController?.fetchedObjects as? [WordPressData.Notification] else { + let notifications = tableViewHandler?.resultsController?.fetchedObjects as? [WordPressData.Notification] + else { return 0 } @@ -1451,17 +1580,25 @@ extension NotificationsViewController: WPTableViewHandlerDelegate { private static func accessibilityHint(for note: WordPressData.Notification) -> String? { switch note.kind { case .comment: - return NSLocalizedString("Shows details and moderation actions.", - comment: "Accessibility hint for a comment notification.") + return NSLocalizedString( + "Shows details and moderation actions.", + comment: "Accessibility hint for a comment notification." + ) case .commentLike, .like: - return NSLocalizedString("Shows all likes.", - comment: "Accessibility hint for a post or comment “like” notification.") + return NSLocalizedString( + "Shows all likes.", + comment: "Accessibility hint for a post or comment “like” notification." + ) case .follow: - return NSLocalizedString("Shows all followers", - comment: "Accessibility hint for a follow notification.") + return NSLocalizedString( + "Shows all followers", + comment: "Accessibility hint for a follow notification." + ) case .matcher, .newPost: - return NSLocalizedString("Shows the post", - comment: "Accessibility hint for a match/mention on a post notification.") + return NSLocalizedString( + "Shows the post", + comment: "Accessibility hint for a match/mention on a post notification." + ) default: return nil } @@ -1476,9 +1613,12 @@ private extension NotificationsViewController { return } - UIView.animate(withDuration: 0.33, animations: { - self.filterTabBar.isHidden = false - }) + UIView.animate( + withDuration: 0.33, + animations: { + self.filterTabBar.isHidden = false + } + ) } func hideFiltersSegmentedControlIfApplicable() { @@ -1491,7 +1631,7 @@ private extension NotificationsViewController { // Filters should only be hidden whenever there are no Notifications in the bucket (contrary to the FRC's // results, which are filtered by the active predicate!). // - return mainContext.countObjects(ofType: Notification.self) > 0 && !shouldDisplayJetpackPrompt + mainContext.countObjects(ofType: Notification.self) > 0 && !shouldDisplayJetpackPrompt } } @@ -1531,13 +1671,19 @@ private extension NotificationsViewController { switch filter { case .none, .comment, .follow, .like: Button(Strings.openReader) { - WPAnalytics.track(.notificationsTappedViewReader, withProperties: [Stats.sourceKey: Stats.sourceValue]) + WPAnalytics.track( + .notificationsTappedViewReader, + withProperties: [Stats.sourceKey: Stats.sourceValue] + ) RootViewCoordinator.sharedPresenter.showReader() } case .unread: Button(Strings.createPost) { - WPAnalytics.track(.notificationsTappedNewPost, withProperties: [Stats.sourceKey: Stats.sourceValue]) - RootViewCoordinator.sharedPresenter.showPostEditor() + WPAnalytics.track( + .notificationsTappedNewPost, + withProperties: [Stats.sourceKey: Stats.sourceValue] + ) + RootViewCoordinator.sharedPresenter.showNewPostEditor() } } } @@ -1548,7 +1694,9 @@ private extension NotificationsViewController { } func showNoConnectionView() { - noResultsViewController.rootView = AnyView(EmptyStateView(noConnectionTitleText, systemImage: "network.slash", description: noConnectionMessage())) + noResultsViewController.rootView = AnyView( + EmptyStateView(noConnectionTitleText, systemImage: "network.slash", description: noConnectionMessage()) + ) addNoResultsToView() } @@ -1570,27 +1718,30 @@ private extension NotificationsViewController { } var noConnectionTitleText: String { - return NSLocalizedString("Unable to Sync", comment: "Title of error prompt shown when a sync the user initiated fails.") + NSLocalizedString( + "Unable to Sync", + comment: "Title of error prompt shown when a sync the user initiated fails." + ) } var noResultsButtonText: String? { - return filter.noResultsButtonTitle + filter.noResultsButtonTitle } var shouldDisplayJetpackPrompt: Bool { - return AccountHelper.isDotcomAvailable() == false && blogForJetpackPrompt != nil + AccountHelper.isDotcomAvailable() == false && blogForJetpackPrompt != nil } var shouldDisplaySettingsButton: Bool { - return AccountHelper.isDotcomAvailable() + AccountHelper.isDotcomAvailable() } var shouldDisplayNoResultsView: Bool { - return tableViewHandler?.resultsController?.fetchedObjects?.isEmpty == true && !shouldDisplayJetpackPrompt + tableViewHandler?.resultsController?.fetchedObjects?.isEmpty == true && !shouldDisplayJetpackPrompt } var shouldDisplayFullscreenNoResultsView: Bool { - return shouldDisplayNoResultsView && filter == .none + shouldDisplayNoResultsView && filter == .none } } @@ -1600,27 +1751,46 @@ internal extension NotificationsViewController { func showInlinePrompt() { guard inlinePromptView.alpha != 1, userDefaults.notificationPrimerAlertWasDisplayed, - userDefaults.notificationsTabAccessCount >= Constants.inlineTabAccessCount else { + userDefaults.notificationsTabAccessCount >= Constants.inlineTabAccessCount + else { return } - UIView.animate(withDuration: 0.33, delay: 0, options: .curveEaseIn, animations: { - self.inlinePromptView.isHidden = false - }) + UIView.animate( + withDuration: 0.33, + delay: 0, + options: .curveEaseIn, + animations: { + self.inlinePromptView.isHidden = false + } + ) - UIView.animate(withDuration: 0.33 * 0.5, delay: 0.33 * 0.75, options: .curveEaseIn, animations: { - self.inlinePromptView.alpha = 1 - }) + UIView.animate( + withDuration: 0.33 * 0.5, + delay: 0.33 * 0.75, + options: .curveEaseIn, + animations: { + self.inlinePromptView.alpha = 1 + } + ) } func hideInlinePrompt(delay: TimeInterval) { - UIView.animate(withDuration: 0.33 * 0.75, delay: delay, animations: { - self.inlinePromptView.alpha = 0 - }) + UIView.animate( + withDuration: 0.33 * 0.75, + delay: delay, + animations: { + self.inlinePromptView.alpha = 0 + } + ) - UIView.animate(withDuration: 0.33, delay: delay + 0.33 * 0.5, animations: { - self.inlinePromptView.isHidden = true - }) + UIView.animate( + withDuration: 0.33, + delay: delay + 0.33 * 0.5, + animations: { + self.inlinePromptView.isHidden = true + } + ) } } @@ -1636,30 +1806,36 @@ private extension NotificationsViewController { mediator?.sync() } - func syncNotification(with noteId: String, timeout: TimeInterval, success: @escaping (_ note: WordPressData.Notification) -> Void) { + func syncNotification( + with noteId: String, + timeout: TimeInterval, + success: @escaping (_ note: WordPressData.Notification) -> Void + ) { let mediator = NotificationSyncMediator() let startDate = Date() DDLogInfo("Sync'ing Notification [\(noteId)]") - mediator?.syncNote(with: noteId) { _, note in - guard abs(startDate.timeIntervalSinceNow) <= timeout else { - DDLogError("Error: Timeout while trying to load Notification [\(noteId)]") - return - } + mediator? + .syncNote(with: noteId) { _, note in + guard abs(startDate.timeIntervalSinceNow) <= timeout else { + DDLogError("Error: Timeout while trying to load Notification [\(noteId)]") + return + } - guard let note else { - DDLogError("Error: Couldn't load Notification [\(noteId)]") - return - } + guard let note else { + DDLogError("Error: Couldn't load Notification [\(noteId)]") + return + } - DDLogInfo("Notification Sync'ed in \(startDate.timeIntervalSinceNow) seconds") - success(note) - } + DDLogInfo("Notification Sync'ed in \(startDate.timeIntervalSinceNow) seconds") + success(note) + } } func updateLastSeenTime() { - guard let note = tableViewHandler?.resultsController?.fetchedObjects?.first as? WordPressData.Notification else { + guard let note = tableViewHandler?.resultsController?.fetchedObjects?.first as? WordPressData.Notification + else { return } @@ -1672,8 +1848,12 @@ private extension NotificationsViewController { return mainContext.firstObject(ofType: Notification.self, matching: predicate) } - func loadNotification(near note: WordPressData.Notification, withIndexDelta delta: Int) -> WordPressData.Notification? { - guard let notifications = tableViewHandler?.resultsController?.fetchedObjects as? [WordPressData.Notification] else { + func loadNotification( + near note: WordPressData.Notification, + withIndexDelta delta: Int + ) -> WordPressData.Notification? { + guard let notifications = tableViewHandler?.resultsController?.fetchedObjects as? [WordPressData.Notification] + else { return nil } @@ -1705,11 +1885,11 @@ private extension NotificationsViewController { // extension NotificationsViewController: NotificationsNavigationDataSource { @objc func notification(succeeding note: WordPressData.Notification) -> WordPressData.Notification? { - return loadNotification(near: note, withIndexDelta: -1) + loadNotification(near: note, withIndexDelta: -1) } @objc func notification(preceding note: WordPressData.Notification) -> WordPressData.Notification? { - return loadNotification(near: note, withIndexDelta: +1) + loadNotification(near: note, withIndexDelta: +1) } } @@ -1717,16 +1897,22 @@ extension NotificationsViewController: NotificationsNavigationDataSource { // extension NotificationsViewController: SearchableActivityConvertable { var activityType: String { - return WPActivityType.notifications.rawValue + WPActivityType.notifications.rawValue } var activityTitle: String { - return NSLocalizedString("Notifications", comment: "Title of the 'Notifications' tab - used for spotlight indexing on iOS.") + NSLocalizedString( + "Notifications", + comment: "Title of the 'Notifications' tab - used for spotlight indexing on iOS." + ) } var activityKeywords: Set? { - let keyWordString = NSLocalizedString("wordpress, notifications, alerts, updates", - comment: "This is a comma separated list of keywords used for spotlight indexing of the 'Notifications' tab.") + let keyWordString = NSLocalizedString( + "wordpress, notifications, alerts, updates", + comment: + "This is a comma separated list of keywords used for spotlight indexing of the 'Notifications' tab." + ) let keywordArray = keyWordString.arrayOfTags() guard !keywordArray.isEmpty else { @@ -1741,11 +1927,11 @@ extension NotificationsViewController: SearchableActivityConvertable { // private extension NotificationsViewController { var mainContext: NSManagedObjectContext { - return ContextManager.shared.mainContext + ContextManager.shared.mainContext } var userDefaults: UserPersistentRepository { - return UserPersistentStoreFactory.instance() + UserPersistentStoreFactory.instance() } var filter: Filter { @@ -1768,82 +1954,147 @@ private extension NotificationsViewController { var condition: String? { switch self { - case .none: return nil - case .unread: return "read = NO" - case .comment: return "type = '\(NotificationKind.comment.rawValue)'" - case .follow: return "type = '\(NotificationKind.follow.rawValue)'" - case .like: return "type = '\(NotificationKind.like.rawValue)' OR type = '\(NotificationKind.commentLike.rawValue)'" + case .none: return nil + case .unread: return "read = NO" + case .comment: return "type = '\(NotificationKind.comment.rawValue)'" + case .follow: return "type = '\(NotificationKind.follow.rawValue)'" + case .like: + return "type = '\(NotificationKind.like.rawValue)' OR type = '\(NotificationKind.commentLike.rawValue)'" } } var title: String { switch self { - case .none: return NSLocalizedString("All", comment: "Displays all of the Notifications, unfiltered") - case .unread: return NSLocalizedString("Unread", comment: "Filters Unread Notifications") - case .comment: return NSLocalizedString("Comments", comment: "Filters Comments Notifications") - case .follow: return NSLocalizedString("notifications.filter.subscribers.title", value: "Subscribers", comment: "Filters Subscribers Notifications") - case .like: return NSLocalizedString("Likes", comment: "Filters Likes Notifications") + case .none: return NSLocalizedString("All", comment: "Displays all of the Notifications, unfiltered") + case .unread: return NSLocalizedString("Unread", comment: "Filters Unread Notifications") + case .comment: return NSLocalizedString("Comments", comment: "Filters Comments Notifications") + case .follow: + return NSLocalizedString( + "notifications.filter.subscribers.title", + value: "Subscribers", + comment: "Filters Subscribers Notifications" + ) + case .like: return NSLocalizedString("Likes", comment: "Filters Likes Notifications") } } var analyticsTitle: String { switch self { - case .none: return "All" - case .unread: return "Unread" - case .comment: return "Comments" - case .follow: return "Follows" - case .like: return "Likes" + case .none: return "All" + case .unread: return "Unread" + case .comment: return "Comments" + case .follow: return "Follows" + case .like: return "Likes" } } var confirmationMessageTitle: String { switch self { - case .none: return "" - case .unread: return NSLocalizedString("unread", comment: "Displayed in the confirmation alert when marking unread notifications as read.") - case .comment: return NSLocalizedString("comment", comment: "Displayed in the confirmation alert when marking comment notifications as read.") - case .follow: return NSLocalizedString("notifications.filter.subscriptions.confirmationMessageTitle", value: "subscribe", comment: "Displayed in the confirmation alert when marking follow notifications as read.") - case .like: return NSLocalizedString("like", comment: "Displayed in the confirmation alert when marking like notifications as read.") + case .none: return "" + case .unread: + return NSLocalizedString( + "unread", + comment: "Displayed in the confirmation alert when marking unread notifications as read." + ) + case .comment: + return NSLocalizedString( + "comment", + comment: "Displayed in the confirmation alert when marking comment notifications as read." + ) + case .follow: + return NSLocalizedString( + "notifications.filter.subscriptions.confirmationMessageTitle", + value: "subscribe", + comment: "Displayed in the confirmation alert when marking follow notifications as read." + ) + case .like: + return NSLocalizedString( + "like", + comment: "Displayed in the confirmation alert when marking like notifications as read." + ) } } var noResultsTitle: String { switch self { - case .none: return NSLocalizedString("No notifications yet", - comment: "Displayed in the Notifications Tab as a title, when there are no notifications") - case .unread: return NSLocalizedString("You're all up to date!", - comment: "Displayed in the Notifications Tab as a title, when the Unread Filter shows no unread notifications as a title") - case .comment: return NSLocalizedString("No comments yet", - comment: "Displayed in the Notifications Tab as a title, when the Comments Filter shows no notifications") - case .follow: return NSLocalizedString("notifications.noresults.subscribers", value: "No subscribers yet", - comment: "Displayed in the Notifications Tab as a title, when the Subscriber Filter shows no notifications") - case .like: return NSLocalizedString("No likes yet", - comment: "Displayed in the Notifications Tab as a title, when the Likes Filter shows no notifications") + case .none: + return NSLocalizedString( + "No notifications yet", + comment: "Displayed in the Notifications Tab as a title, when there are no notifications" + ) + case .unread: + return NSLocalizedString( + "You're all up to date!", + comment: + "Displayed in the Notifications Tab as a title, when the Unread Filter shows no unread notifications as a title" + ) + case .comment: + return NSLocalizedString( + "No comments yet", + comment: + "Displayed in the Notifications Tab as a title, when the Comments Filter shows no notifications" + ) + case .follow: + return NSLocalizedString( + "notifications.noresults.subscribers", + value: "No subscribers yet", + comment: + "Displayed in the Notifications Tab as a title, when the Subscriber Filter shows no notifications" + ) + case .like: + return NSLocalizedString( + "No likes yet", + comment: + "Displayed in the Notifications Tab as a title, when the Likes Filter shows no notifications" + ) } } var noResultsMessage: String { switch self { - case .none: return NSLocalizedString("Get active! Comment on posts from blogs you follow.", - comment: "Displayed in the Notifications Tab as a message, when there are no notifications") - case .unread: return NSLocalizedString("Reignite the conversation: write a new post.", - comment: "Displayed in the Notifications Tab as a message, when the Unread Filter shows no notifications") - case .comment: return NSLocalizedString("Join a conversation: comment on posts from blogs you follow.", - comment: "Displayed in the Notifications Tab as a message, when the Comments Filter shows no notifications") + case .none: + return NSLocalizedString( + "Get active! Comment on posts from blogs you follow.", + comment: "Displayed in the Notifications Tab as a message, when there are no notifications" + ) + case .unread: + return NSLocalizedString( + "Reignite the conversation: write a new post.", + comment: + "Displayed in the Notifications Tab as a message, when the Unread Filter shows no notifications" + ) + case .comment: + return NSLocalizedString( + "Join a conversation: comment on posts from blogs you follow.", + comment: + "Displayed in the Notifications Tab as a message, when the Comments Filter shows no notifications" + ) case .follow, - .like: return NSLocalizedString("Get noticed: comment on posts you've read.", - comment: "Displayed in the Notifications Tab as a message, when the Follow Filter shows no notifications") + .like: + return NSLocalizedString( + "Get noticed: comment on posts you've read.", + comment: + "Displayed in the Notifications Tab as a message, when the Follow Filter shows no notifications" + ) } } var noResultsButtonTitle: String { switch self { case .none, - .comment, - .follow, - .like: return NSLocalizedString("Go to Reader", - comment: "Displayed in the Notifications Tab as a button title, when there are no notifications") - case .unread: return NSLocalizedString("Create a Post", - comment: "Displayed in the Notifications Tab as a button title, when the Unread Filter shows no notifications") + .comment, + .follow, + .like: + return NSLocalizedString( + "Go to Reader", + comment: "Displayed in the Notifications Tab as a button title, when there are no notifications" + ) + case .unread: + return NSLocalizedString( + "Create a Post", + comment: + "Displayed in the Notifications Tab as a button title, when the Unread Filter shows no notifications" + ) } } @@ -1964,13 +2215,21 @@ extension NotificationsViewController: JPScrollViewDelegate { extension NotificationsViewController: StoryboardLoadable { static var defaultStoryboardName: String { - return "Notifications" + "Notifications" } } private enum Strings { - static let openReader = NSLocalizedString("notifications.emptyState.buttonOpenReader", value: "Open Reader", comment: "Displayed in the Notifications Tab as a button title, when there are no notifications") - static let createPost = NSLocalizedString("notifications.emptyState.buttonCreatePost", value: "Create Post", comment: "Displayed in the Notifications Tab as a button title, when the Unread Filter shows no notifications") + static let openReader = NSLocalizedString( + "notifications.emptyState.buttonOpenReader", + value: "Open Reader", + comment: "Displayed in the Notifications Tab as a button title, when there are no notifications" + ) + static let createPost = NSLocalizedString( + "notifications.emptyState.buttonCreatePost", + value: "Create Post", + comment: "Displayed in the Notifications Tab as a button title, when the Unread Filter shows no notifications" + ) enum NavigationBar { static let notificationSettingsActionTitle = NSLocalizedString( diff --git a/WordPress/Classes/ViewRelated/Pages/Controllers/PageListViewController.swift b/WordPress/Classes/ViewRelated/Pages/Controllers/PageListViewController.swift index bc76180b152f..e18415e63e02 100644 --- a/WordPress/Classes/ViewRelated/Pages/Controllers/PageListViewController.swift +++ b/WordPress/Classes/ViewRelated/Pages/Controllers/PageListViewController.swift @@ -32,9 +32,12 @@ final class PageListViewController: AbstractPostListViewController { private lazy var homepageSettingsService = HomepageSettingsService(blog: blog, coreDataStack: ContextManager.shared) private lazy var createButtonCoordinator: CreateButtonCoordinator = { - let action = PageAction(handler: { [weak self] in - self?.createPost() - }, source: Constant.Events.source) + let action = PageAction( + handler: { [weak self] in + self?.createPost() + }, + source: Constant.Events.source + ) return CreateButtonCoordinator(self, actions: [action], source: Constant.Events.source) }() @@ -46,7 +49,10 @@ final class PageListViewController: AbstractPostListViewController { return isFSETheme && filterSettings.currentPostListFilter().filterType == .published } - private lazy var editorSettingsService = BlockEditorSettingsService(blog: blog, coreDataStack: ContextManager.shared) + private lazy var editorSettingsService = BlockEditorSettingsService( + blog: blog, + coreDataStack: ContextManager.shared + ) private var pages: [Page] = [] @@ -75,7 +81,11 @@ final class PageListViewController: AbstractPostListViewController { title = NSLocalizedString("Pages", comment: "Title of the screen showing the list of pages for a blog.") - createButtonCoordinator.add(to: view, trailingAnchor: view.safeAreaLayoutGuide.trailingAnchor, bottomAnchor: view.safeAreaLayoutGuide.bottomAnchor) + createButtonCoordinator.add( + to: view, + trailingAnchor: view.safeAreaLayoutGuide.trailingAnchor, + bottomAnchor: view.safeAreaLayoutGuide.bottomAnchor + ) refreshNoResultsViewController = { [weak self] in self?.handleRefreshNoResultsViewController($0) @@ -112,18 +122,24 @@ final class PageListViewController: AbstractPostListViewController { tableView.estimatedRowHeight = Constant.Size.pageCellEstimatedRowHeight tableView.register(PageListCell.self, forCellReuseIdentifier: Constant.Identifiers.pageCellIdentifier) - tableView.register(TemplatePageTableViewCell.self, forCellReuseIdentifier: Constant.Identifiers.templatePageCellIdentifier) + tableView.register( + TemplatePageTableViewCell.self, + forCellReuseIdentifier: Constant.Identifiers.templatePageCellIdentifier + ) } private func beginRefreshingManually() { refreshControl.beginRefreshing() - tableView.setContentOffset(CGPoint(x: 0, y: tableView.contentOffset.y - refreshControl.frame.size.height), animated: true) + tableView.setContentOffset( + CGPoint(x: 0, y: tableView.contentOffset.y - refreshControl.frame.size.height), + animated: true + ) } // MARK: - Sync Methods override internal func postTypeToSync() -> PostServiceType { - return .page + .page } @MainActor @@ -142,19 +158,34 @@ final class PageListViewController: AbstractPostListViewController { return (posts, false) } - override func syncHelper(_ syncHelper: WPContentSyncHelper, syncContentWithUserInteraction userInteraction: Bool, success: ((Bool) -> ())?, failure: ((NSError) -> ())?) { + override func syncHelper( + _ syncHelper: WPContentSyncHelper, + syncContentWithUserInteraction userInteraction: Bool, + success: ((Bool) -> ())?, + failure: ((NSError) -> ())? + ) { // The success and failure blocks are called in the parent class `AbstractPostListViewController` by the `syncPosts` method. Since that class is // used by both this one and the "Posts" screen, making changes to the sync helper is tough. To get around that, we make the fetch settings call // async and then just await it before calling either the final success or failure block. This ensures that both the `syncPosts` call in the parent // and the `fetchSettings` call here finish before calling the final success or failure block. let (wrappedSuccess, wrappedFailure) = fetchEditorSettings(success: success, failure: failure) - super.syncHelper(syncHelper, syncContentWithUserInteraction: userInteraction, success: wrappedSuccess, failure: wrappedFailure) - } - - private func fetchEditorSettings(success: ((Bool) -> ())?, failure: ((NSError) -> ())?) -> (success: (_ hasMore: Bool) -> (), failure: (NSError) -> ()) { + super + .syncHelper( + syncHelper, + syncContentWithUserInteraction: userInteraction, + success: wrappedSuccess, + failure: wrappedFailure + ) + } + + private func fetchEditorSettings( + success: ((Bool) -> ())?, + failure: ((NSError) -> ())? + ) -> (success: (_ hasMore: Bool) -> (), failure: (NSError) -> ()) { let fetchTask = Task { @MainActor [weak self] in guard RemoteFeatureFlag.siteEditorMVP.enabled(), - let result = await self?.editorSettingsService?.fetchSettings() else { + let result = await self?.editorSettingsService?.fetchSettings() + else { return } switch result { @@ -232,7 +263,13 @@ final class PageListViewController: AbstractPostListViewController { // Do nothing } - override func controller(_ controller: NSFetchedResultsController, didChange anObject: Any, at indexPath: IndexPath?, for type: NSFetchedResultsChangeType, newIndexPath: IndexPath?) { + override func controller( + _ controller: NSFetchedResultsController, + didChange anObject: Any, + at indexPath: IndexPath?, + for type: NSFetchedResultsChangeType, + newIndexPath: IndexPath? + ) { // Do nothing, refresh all } @@ -245,7 +282,7 @@ final class PageListViewController: AbstractPostListViewController { // MARK: - Core Data override func entityName() -> String { - return String(describing: Page.self) + String(describing: Page.self) } override func predicateForFetchRequest() -> NSPredicate { @@ -276,10 +313,11 @@ final class PageListViewController: AbstractPostListViewController { } if RemoteFeatureFlag.siteEditorMVP.enabled(), - blog.blockEditorSettings?.isFSETheme ?? false, - let homepageID = blog.homepagePageID, - let homepageType = blog.homepageType, - homepageType == .page { + blog.blockEditorSettings?.isFSETheme ?? false, + let homepageID = blog.homepagePageID, + let homepageType = blog.homepageType, + homepageType == .page + { predicates.append(NSPredicate(format: "postID != %i", homepageID)) } @@ -298,9 +336,11 @@ final class PageListViewController: AbstractPostListViewController { return } - let webViewController = WebViewControllerFactory.controller(url: editorUrl, - blog: blog, - source: Constant.Events.editHomepageSource) + let webViewController = WebViewControllerFactory.controller( + url: editorUrl, + blog: blog, + source: Constant.Events.editHomepageSource + ) let navigationController = UINavigationController(rootViewController: webViewController) present(navigationController, animated: true) case .pages: @@ -310,7 +350,11 @@ final class PageListViewController: AbstractPostListViewController { } } - func tableView(_ tableView: UITableView, contextMenuConfigurationForRowAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? { + func tableView( + _ tableView: UITableView, + contextMenuConfigurationForRowAt indexPath: IndexPath, + point: CGPoint + ) -> UIContextMenuConfiguration? { guard indexPath.section == Section.pages.rawValue else { return nil } return UIContextMenuConfiguration(identifier: nil, previewProvider: nil) { [weak self] _ in guard let self else { return nil } @@ -320,13 +364,19 @@ final class PageListViewController: AbstractPostListViewController { } } - func tableView(_ tableView: UITableView, leadingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? { + func tableView( + _ tableView: UITableView, + leadingSwipeActionsConfigurationForRowAt indexPath: IndexPath + ) -> UISwipeActionsConfiguration? { guard indexPath.section == Section.pages.rawValue else { return nil } let actions = AbstractPostHelper.makeLeadingContextualActions(for: pages[indexPath.row], delegate: self) return UISwipeActionsConfiguration(actions: actions) } - func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? { + func tableView( + _ tableView: UITableView, + trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath + ) -> UISwipeActionsConfiguration? { guard indexPath.section == Section.pages.rawValue else { return nil } let actions = AbstractPostHelper.makeTrailingContextualActions(for: pages[indexPath.row], delegate: self) return UISwipeActionsConfiguration(actions: actions) @@ -354,19 +404,29 @@ final class PageListViewController: AbstractPostListViewController { let cell = tableView.dequeueReusableCell(withIdentifier: identifier, for: indexPath) return cell case .pages: - let cell = tableView.dequeueReusableCell(withIdentifier: Constant.Identifiers.pageCellIdentifier, for: indexPath) as! PageListCell + let cell = + tableView.dequeueReusableCell(withIdentifier: Constant.Identifiers.pageCellIdentifier, for: indexPath) + as! PageListCell let page = pages[indexPath.row] let indentation = getIndentationLevel(at: indexPath) - let isFirstSubdirectory = getIndentationLevel(at: IndexPath(row: indexPath.row - 1, section: indexPath.section)) == (indentation - 1) + let isFirstSubdirectory = + getIndentationLevel(at: IndexPath(row: indexPath.row - 1, section: indexPath.section)) + == (indentation - 1) let viewModel = PageListItemViewModel(page: page) - cell.configure(with: viewModel, indentation: indentation, isFirstSubdirectory: isFirstSubdirectory, delegate: self) + cell.configure( + with: viewModel, + indentation: indentation, + isFirstSubdirectory: isFirstSubdirectory, + delegate: self + ) return cell } } private func getIndentationLevel(at indexPath: IndexPath) -> Int { guard filterSettings.currentPostListFilter().filterType == .published, - indexPath.row > 0 else { + indexPath.row > 0 + else { return 0 } return pages[indexPath.row].hierarchyIndex @@ -375,16 +435,31 @@ final class PageListViewController: AbstractPostListViewController { // MARK: - Post Actions override func createPost() { - WPAppAnalytics.track(.editorCreatedPost, properties: [WPAppAnalyticsKeyTapSource: Constant.Events.source, WPAppAnalyticsKeyPostType: Constant.Events.pagePostType], blog: blog) + var context = NewPostEditorContext( + entryPoint: .pagesList, + analytics: .editorCreatedPost( + source: Constant.Events.source, + postType: Constant.Events.pagePostType + ), + animated: false + ) + context.trackAnalytics(for: blog) + context.analytics = .none PageCoordinator.showLayoutPickerIfNeeded(from: self, forBlog: blog) { [weak self] selectedLayout in - self?.createPage(selectedLayout) + self?.createPage(selectedLayout, context: context) } } - private func createPage(_ starterLayout: PageTemplateLayout?) { - let editorViewController = EditPageViewController(blog: blog, postTitle: starterLayout?.title, content: starterLayout?.content) - present(editorViewController, animated: false) + private func createPage(_ starterLayout: PageTemplateLayout?, context: NewPostEditorContext) { + var context = context + context.title = starterLayout?.title + context.content = starterLayout?.content + PostEditorRouter.showNewPage( + for: blog, + from: self, + context: context + ) } // MARK: - Cell Action Handling @@ -392,26 +467,38 @@ final class PageListViewController: AbstractPostListViewController { func setPageAsHomepage(_ page: Page) { guard let homePageID = page.postID?.intValue else { return } beginRefreshingManually() - homepageSettingsService?.setHomepageType(.page, homePageID: homePageID, success: { [weak self] in - self?.refreshAndReload() - self?.handleHomepageSettingsSuccess() - }, failure: { [weak self] _ in - self?.refreshControl.endRefreshing() - self?.handleHomepageSettingsFailure() - }) + homepageSettingsService? + .setHomepageType( + .page, + homePageID: homePageID, + success: { [weak self] in + self?.refreshAndReload() + self?.handleHomepageSettingsSuccess() + }, + failure: { [weak self] _ in + self?.refreshControl.endRefreshing() + self?.handleHomepageSettingsFailure() + } + ) } func togglePageAsPostsPage(_ page: Page) { let newValue = !page.isSitePostsPage let postsPageID = page.isSitePostsPage ? 0 : (page.postID?.intValue ?? 0) beginRefreshingManually() - homepageSettingsService?.setHomepageType(.page, withPostsPageID: postsPageID, success: { [weak self] in - self?.refreshAndReload() - self?.handleHomepagePostsPageSettingsSuccess(isPostsPage: newValue) - }, failure: { [weak self] _ in - self?.refreshControl.endRefreshing() - self?.handleHomepageSettingsFailure() - }) + homepageSettingsService? + .setHomepageType( + .page, + withPostsPageID: postsPageID, + success: { [weak self] in + self?.refreshAndReload() + self?.handleHomepagePostsPageSettingsSuccess(isPostsPage: newValue) + }, + failure: { [weak self] _ in + self?.refreshControl.endRefreshing() + self?.handleHomepageSettingsFailure() + } + ) } private func handleHomepageSettingsSuccess() { @@ -420,29 +507,52 @@ final class PageListViewController: AbstractPostListViewController { } private func handleHomepagePostsPageSettingsSuccess(isPostsPage: Bool) { - let title = isPostsPage ? HomepageSettingsText.updatePostsPageSuccessTitle : HomepageSettingsText.updatePageSuccessTitle + let title = + isPostsPage ? HomepageSettingsText.updatePostsPageSuccessTitle : HomepageSettingsText.updatePageSuccessTitle let notice = Notice(title: title, feedbackType: .success) ActionDispatcher.global.dispatch(NoticeAction.post(notice)) } private func handleHomepageSettingsFailure() { - let notice = Notice(title: HomepageSettingsText.updateErrorTitle, message: HomepageSettingsText.updateErrorMessage, feedbackType: .error) + let notice = Notice( + title: HomepageSettingsText.updateErrorTitle, + message: HomepageSettingsText.updateErrorMessage, + feedbackType: .error + ) ActionDispatcher.global.dispatch(NoticeAction.post(notice)) } // MARK: - NetworkAwareUI override func noConnectionMessage() -> String { - return NSLocalizedString("No internet connection. Some pages may be unavailable while offline.", - comment: "Error message shown when the user is browsing Site Pages without an internet connection.") + NSLocalizedString( + "No internet connection. Some pages may be unavailable while offline.", + comment: "Error message shown when the user is browsing Site Pages without an internet connection." + ) } struct HomepageSettingsText { - static let updateErrorTitle = NSLocalizedString("Unable to update homepage settings", comment: "Error informing the user that their homepage settings could not be updated") - static let updateErrorMessage = NSLocalizedString("Please try again later.", comment: "Prompt for the user to retry a failed action again later") - static let updatePageSuccessTitle = NSLocalizedString("pages.updatePage.successTitle", value: "Page successfully updated", comment: "Message informing the user that their static homepage page was set successfully") - static let updateHomepageSuccessTitle = NSLocalizedString("Homepage successfully updated", comment: "Message informing the user that their static homepage page was set successfully") - static let updatePostsPageSuccessTitle = NSLocalizedString("Posts page successfully updated", comment: "Message informing the user that their static homepage for posts was set successfully") + static let updateErrorTitle = NSLocalizedString( + "Unable to update homepage settings", + comment: "Error informing the user that their homepage settings could not be updated" + ) + static let updateErrorMessage = NSLocalizedString( + "Please try again later.", + comment: "Prompt for the user to retry a failed action again later" + ) + static let updatePageSuccessTitle = NSLocalizedString( + "pages.updatePage.successTitle", + value: "Page successfully updated", + comment: "Message informing the user that their static homepage page was set successfully" + ) + static let updateHomepageSuccessTitle = NSLocalizedString( + "Homepage successfully updated", + comment: "Message informing the user that their static homepage page was set successfully" + ) + static let updatePostsPageSuccessTitle = NSLocalizedString( + "Posts page successfully updated", + comment: "Message informing the user that their static homepage for posts was set successfully" + ) } } @@ -453,20 +563,33 @@ private extension PageListViewController { func handleRefreshNoResultsViewController(_ noResultsViewController: NoResultsViewController) { guard connectionAvailable() else { - noResultsViewController.configure(title: "", noConnectionTitle: NoResultsText.noConnectionTitle, buttonTitle: nil, subtitle: nil, noConnectionSubtitle: NoResultsText.noConnectionSubtitle, attributedSubtitle: nil, attributedSubtitleConfiguration: nil, image: nil, subtitleImage: nil, accessoryView: nil) + noResultsViewController.configure( + title: "", + noConnectionTitle: NoResultsText.noConnectionTitle, + buttonTitle: nil, + subtitle: nil, + noConnectionSubtitle: NoResultsText.noConnectionSubtitle, + attributedSubtitle: nil, + attributedSubtitleConfiguration: nil, + image: nil, + subtitleImage: nil, + accessoryView: nil + ) return } let accessoryView = syncHelper.isSyncing ? NoResultsViewController.loadingAccessoryView() : nil - noResultsViewController.configure(title: noResultsTitle(), - buttonTitle: nil, - image: noResultsImageName, - accessoryView: accessoryView) + noResultsViewController.configure( + title: noResultsTitle(), + buttonTitle: nil, + image: noResultsImageName, + accessoryView: accessoryView + ) } var noResultsImageName: String { - return "pages-no-results" + "pages-no-results" } func noResultsTitle() -> String { @@ -493,11 +616,29 @@ private extension PageListViewController { } struct NoResultsText { - static let noDraftsTitle = NSLocalizedString("You don't have any draft pages", comment: "Displayed when the user views drafts in the pages list and there are no pages") - static let noScheduledTitle = NSLocalizedString("You don't have any scheduled pages", comment: "Displayed when the user views scheduled pages in the pages list and there are no pages") - static let noTrashedTitle = NSLocalizedString("You don't have any trashed pages", comment: "Displayed when the user views trashed in the pages list and there are no pages") - static let noPublishedTitle = NSLocalizedString("You haven't published any pages yet", comment: "Displayed when the user views published pages in the pages list and there are no pages") - static let noConnectionTitle: String = NSLocalizedString("Unable to load pages right now.", comment: "Title for No results full page screen displayedfrom pages list when there is no connection") - static let noConnectionSubtitle: String = NSLocalizedString("Check your network connection and try again. Or draft a page.", comment: "Subtitle for No results full page screen displayed from pages list when there is no connection") + static let noDraftsTitle = NSLocalizedString( + "You don't have any draft pages", + comment: "Displayed when the user views drafts in the pages list and there are no pages" + ) + static let noScheduledTitle = NSLocalizedString( + "You don't have any scheduled pages", + comment: "Displayed when the user views scheduled pages in the pages list and there are no pages" + ) + static let noTrashedTitle = NSLocalizedString( + "You don't have any trashed pages", + comment: "Displayed when the user views trashed in the pages list and there are no pages" + ) + static let noPublishedTitle = NSLocalizedString( + "You haven't published any pages yet", + comment: "Displayed when the user views published pages in the pages list and there are no pages" + ) + static let noConnectionTitle: String = NSLocalizedString( + "Unable to load pages right now.", + comment: "Title for No results full page screen displayedfrom pages list when there is no connection" + ) + static let noConnectionSubtitle: String = NSLocalizedString( + "Check your network connection and try again. Or draft a page.", + comment: "Subtitle for No results full page screen displayed from pages list when there is no connection" + ) } } diff --git a/WordPress/Classes/ViewRelated/Post/Controllers/PostListViewController.swift b/WordPress/Classes/ViewRelated/Post/Controllers/PostListViewController.swift index 310082c72f7d..d67d655be498 100644 --- a/WordPress/Classes/ViewRelated/Post/Controllers/PostListViewController.swift +++ b/WordPress/Classes/ViewRelated/Post/Controllers/PostListViewController.swift @@ -17,7 +17,11 @@ final class PostListViewController: AbstractPostListViewController, InteractiveP return vc } - static func showForBlog(_ blog: Blog, from sourceController: UIViewController, withPostStatus postStatus: BasePost.Status? = nil) { + static func showForBlog( + _ blog: Blog, + from sourceController: UIViewController, + withPostStatus postStatus: BasePost.Status? = nil + ) { let controller = PostListViewController.controllerWithBlog(blog) controller.navigationItem.largeTitleDisplayMode = .never controller.initialFilterWithPostStatus = postStatus @@ -34,7 +38,11 @@ final class PostListViewController: AbstractPostListViewController, InteractiveP configureInitialFilterIfNeeded() listenForAppComingToForeground() - createButtonCoordinator.add(to: view, trailingAnchor: view.safeAreaLayoutGuide.trailingAnchor, bottomAnchor: view.safeAreaLayoutGuide.bottomAnchor) + createButtonCoordinator.add( + to: view, + trailingAnchor: view.safeAreaLayoutGuide.trailingAnchor, + bottomAnchor: view.safeAreaLayoutGuide.bottomAnchor + ) refreshNoResultsViewController = { [weak self] in self?.handleRefreshNoResultsViewController($0) @@ -47,10 +55,13 @@ final class PostListViewController: AbstractPostListViewController, InteractiveP private lazy var createButtonCoordinator: CreateButtonCoordinator = { var actions: [ActionSheetItem] = [ - PostAction(handler: { [weak self] in - self?.dismiss(animated: false, completion: nil) - self?.createPost() - }, source: Constants.source) + PostAction( + handler: { [weak self] in + self?.dismiss(animated: false, completion: nil) + self?.createPost() + }, + source: Constants.source + ) ] return CreateButtonCoordinator(self, actions: actions, source: Constants.source, blog: blog) }() @@ -90,16 +101,18 @@ final class PostListViewController: AbstractPostListViewController, InteractiveP /// Listens for the app coming to foreground in order to properly set the create button private func listenForAppComingToForeground() { - NotificationCenter.default.addObserver(self, - selector: #selector(toggleCreateButton), - name: UIApplication.willEnterForegroundNotification, - object: nil) + NotificationCenter.default.addObserver( + self, + selector: #selector(toggleCreateButton), + name: UIApplication.willEnterForegroundNotification, + object: nil + ) } // MARK: - Sync Methods override func postTypeToSync() -> PostServiceType { - return .post + .post } // MARK: - Data Model Interaction @@ -114,7 +127,7 @@ final class PostListViewController: AbstractPostListViewController, InteractiveP // MARK: - TableViewHandler override func entityName() -> String { - return String(describing: Post.self) + String(describing: Post.self) } override func predicateForFetchRequest() -> NSPredicate { @@ -144,7 +157,8 @@ final class PostListViewController: AbstractPostListViewController, InteractiveP // MARK: - UITableViewDataSource override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - let cell = tableView.dequeueReusableCell(withIdentifier: PostListCell.defaultReuseID, for: indexPath) as! PostListCell + let cell = + tableView.dequeueReusableCell(withIdentifier: PostListCell.defaultReuseID, for: indexPath) as! PostListCell let post = postAtIndexPath(indexPath) cell.accessoryType = .none cell.configure(with: PostListItemViewModel(post: post, shouldHideAuthor: shouldHideAuthor), delegate: self) @@ -162,7 +176,11 @@ final class PostListViewController: AbstractPostListViewController, InteractiveP editPost(post) } - func tableView(_ tableView: UITableView, contextMenuConfigurationForRowAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? { + func tableView( + _ tableView: UITableView, + contextMenuConfigurationForRowAt indexPath: IndexPath, + point: CGPoint + ) -> UIContextMenuConfiguration? { UIContextMenuConfiguration(identifier: nil, previewProvider: nil) { [weak self] _ in guard let self else { return nil } let post = self.postAtIndexPath(indexPath) @@ -171,12 +189,18 @@ final class PostListViewController: AbstractPostListViewController, InteractiveP } } - func tableView(_ tableView: UITableView, leadingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? { + func tableView( + _ tableView: UITableView, + leadingSwipeActionsConfigurationForRowAt indexPath: IndexPath + ) -> UISwipeActionsConfiguration? { let actions = AbstractPostHelper.makeLeadingContextualActions(for: postAtIndexPath(indexPath), delegate: self) return UISwipeActionsConfiguration(actions: actions) } - func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? { + func tableView( + _ tableView: UITableView, + trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath + ) -> UISwipeActionsConfiguration? { let actions = AbstractPostHelper.makeTrailingContextualActions(for: postAtIndexPath(indexPath), delegate: self) return UISwipeActionsConfiguration(actions: actions) } @@ -184,11 +208,14 @@ final class PostListViewController: AbstractPostListViewController, InteractiveP // MARK: - Post Actions override func createPost() { - let editor = EditPostViewController(blog: blog) - editor.modalPresentationStyle = .fullScreen - editor.entryPoint = .postsList - present(editor, animated: false, completion: nil) - WPAppAnalytics.track(.editorCreatedPost, properties: [WPAppAnalyticsKeyTapSource: "posts_view", WPAppAnalyticsKeyPostType: "post"], blog: blog) + PostEditorRouter.showNewPost( + for: blog, + from: self, + context: NewPostEditorContext( + entryPoint: .postsList, + analytics: .editorCreatedPost(source: "posts_view", postType: "post") + ) + ) } private func editPost(_ post: AbstractPost) { @@ -243,7 +270,12 @@ final class PostListViewController: AbstractPostListViewController, InteractiveP func comments(_ post: AbstractPost) { WPAnalytics.track(.postListCommentsAction, properties: propertiesForAnalytics()) let contentCoordinator = DefaultContentCoordinator(controller: self, context: ContextManager.shared.mainContext) - try? contentCoordinator.displayCommentsWithPostId(post.postID, siteID: blog.dotComID, commentID: nil, source: .postsList) + try? contentCoordinator.displayCommentsWithPostId( + post.postID, + siteID: blog.dotComID, + commentID: nil, + source: .postsList + ) } func showSettings(for post: AbstractPost) { @@ -254,8 +286,10 @@ final class PostListViewController: AbstractPostListViewController, InteractiveP // MARK: - NetworkAwareUI override func noConnectionMessage() -> String { - return NSLocalizedString("No internet connection. Some posts may be unavailable while offline.", - comment: "Error message shown when the user is browsing Site Posts without an internet connection.") + NSLocalizedString( + "No internet connection. Some posts may be unavailable while offline.", + comment: "Error message shown when the user is browsing Site Posts without an internet connection." + ) } private enum Constants { @@ -270,20 +304,33 @@ private extension PostListViewController { func handleRefreshNoResultsViewController(_ noResultsViewController: NoResultsViewController) { guard connectionAvailable() else { - noResultsViewController.configure(title: "", noConnectionTitle: NoResultsText.noConnectionTitle, buttonTitle: nil, subtitle: nil, noConnectionSubtitle: NoResultsText.noConnectionSubtitle, attributedSubtitle: nil, attributedSubtitleConfiguration: nil, image: nil, subtitleImage: nil, accessoryView: nil) + noResultsViewController.configure( + title: "", + noConnectionTitle: NoResultsText.noConnectionTitle, + buttonTitle: nil, + subtitle: nil, + noConnectionSubtitle: NoResultsText.noConnectionSubtitle, + attributedSubtitle: nil, + attributedSubtitleConfiguration: nil, + image: nil, + subtitleImage: nil, + accessoryView: nil + ) return } let accessoryView = syncHelper.isSyncing ? NoResultsViewController.loadingAccessoryView() : nil - noResultsViewController.configure(title: noResultsTitle(), - buttonTitle: nil, - image: noResultsImageName, - accessoryView: accessoryView) + noResultsViewController.configure( + title: noResultsTitle(), + buttonTitle: nil, + image: noResultsImageName, + accessoryView: accessoryView + ) } var noResultsImageName: String { - return "posts-no-results" + "posts-no-results" } func noResultsTitle() -> String { @@ -310,11 +357,29 @@ private extension PostListViewController { } struct NoResultsText { - static let noDraftsTitle = NSLocalizedString("You don't have any draft posts", comment: "Displayed when the user views drafts in the posts list and there are no posts") - static let noScheduledTitle = NSLocalizedString("You don't have any scheduled posts", comment: "Displayed when the user views scheduled posts in the posts list and there are no posts") - static let noTrashedTitle = NSLocalizedString("You don't have any trashed posts", comment: "Displayed when the user views trashed in the posts list and there are no posts") - static let noPublishedTitle = NSLocalizedString("You haven't published any posts yet", comment: "Displayed when the user views published posts in the posts list and there are no posts") - static let noConnectionTitle: String = NSLocalizedString("Unable to load posts right now.", comment: "Title for No results full page screen displayedfrom post list when there is no connection") - static let noConnectionSubtitle: String = NSLocalizedString("Check your network connection and try again. Or draft a post.", comment: "Subtitle for No results full page screen displayed from post list when there is no connection") + static let noDraftsTitle = NSLocalizedString( + "You don't have any draft posts", + comment: "Displayed when the user views drafts in the posts list and there are no posts" + ) + static let noScheduledTitle = NSLocalizedString( + "You don't have any scheduled posts", + comment: "Displayed when the user views scheduled posts in the posts list and there are no posts" + ) + static let noTrashedTitle = NSLocalizedString( + "You don't have any trashed posts", + comment: "Displayed when the user views trashed in the posts list and there are no posts" + ) + static let noPublishedTitle = NSLocalizedString( + "You haven't published any posts yet", + comment: "Displayed when the user views published posts in the posts list and there are no posts" + ) + static let noConnectionTitle: String = NSLocalizedString( + "Unable to load posts right now.", + comment: "Title for No results full page screen displayedfrom post list when there is no connection" + ) + static let noConnectionSubtitle: String = NSLocalizedString( + "Check your network connection and try again. Or draft a post.", + comment: "Subtitle for No results full page screen displayed from post list when there is no connection" + ) } } diff --git a/WordPress/Classes/ViewRelated/Post/PostEditorAnalyticsSession.swift b/WordPress/Classes/ViewRelated/Post/PostEditorAnalyticsSession.swift index a632655da8fa..b054935068ad 100644 --- a/WordPress/Classes/ViewRelated/Post/PostEditorAnalyticsSession.swift +++ b/WordPress/Classes/ViewRelated/Post/PostEditorAnalyticsSession.swift @@ -15,11 +15,27 @@ struct PostEditorAnalyticsSession { private let startTime = DispatchTime.now().uptimeNanoseconds init(editor: Editor, post: AbstractPost) { - currentEditor = editor - postType = post.analyticsPostType ?? "unsupported" - blogID = post.blog.dotComID - blogType = post.blog.analyticsType.rawValue - contentType = ContentType(post: post).rawValue + self.init( + editor: editor, + blog: post.blog, + postType: post.analyticsPostType ?? "unsupported", + contentType: ContentType(post: post) + ) + } + + init( + editor: Editor, + blog: Blog, + postType: String, + contentType: ContentType, + entryPoint: PostEditorEntryPoint? = nil + ) { + self.currentEditor = editor + self.postType = postType + self.blogID = blog.dotComID + self.blogType = blog.analyticsType.rawValue + self.contentType = contentType.rawValue + self.entryPoint = entryPoint } mutating func start(unsupportedBlocks: [String] = []) { @@ -37,7 +53,7 @@ struct PostEditorAnalyticsSession { // Let's make sure to round the value and send an integer for consistency let startupTimeNanoseconds = DispatchTime.now().uptimeNanoseconds - startTime let startupTimeMilliseconds = Int(Double(startupTimeNanoseconds) / 1_000_000) - var properties: [String: Any] = [ Property.startupTime: startupTimeMilliseconds ] + var properties: [String: Any] = [Property.startupTime: startupTimeMilliseconds] // Tracks custom event types can't be arrays so we need to convert this to JSON if let data = try? JSONSerialization.data(withJSONObject: unsupportedBlocks, options: .fragmentsAllowed) { @@ -60,7 +76,8 @@ struct PostEditorAnalyticsSession { let properties: [String: Any] = [ Property.outcome: outcome.rawValue, Property.entryPoint: (entryPoint ?? .unknown).rawValue - ].merging(commonProperties, uniquingKeysWith: { $1 }) + ] + .merging(commonProperties, uniquingKeysWith: { $1 }) WPAppAnalytics.track(.editorSessionEnd, withProperties: properties) } @@ -83,15 +100,16 @@ private extension PostEditorAnalyticsSession { } var commonProperties: [String: String] { - return [ + [ Property.editor: currentEditor.rawValue, Property.contentType: contentType, Property.postType: postType, Property.blogID: blogID?.stringValue, Property.blogType: blogType, Property.sessionId: sessionId, - Property.hasUnsupportedBlocks: hasUnsupportedBlocks ? "1" : "0", - ].compactMapValues { $0 } + Property.hasUnsupportedBlocks: hasUnsupportedBlocks ? "1" : "0" + ] + .compactMapValues { $0 } } } @@ -109,9 +127,15 @@ extension PostEditorAnalyticsSession { case classic init(post: AbstractPost) { - if post.isContentEmpty() { + self.init(content: post.content) + } + + init(content: String?) { + // A duplication of `BasePost.isContentEmpty` + let emptyGutenbergParagraph = "\n

\n" + if content?.isEmpty != false || content == emptyGutenbergParagraph { self = .new - } else if post.containsGutenbergBlocks() { + } else if content?.contains("