diff --git a/CLAUDE.md b/CLAUDE.md index 5bd6546fc816..4eb62bc582d0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -42,6 +42,7 @@ WordPress-iOS uses a modular architecture with the main app and separate Swift p - Follow Swift API Design Guidelines - Use strict access control modifiers where possible - Use four spaces (not tabs) +- Lines should not have trailing whitespace - Follow the standard formatting practices enforced by SwiftLint - Don't create `body` for `View` that are too long - Use semantics text sizes like `.headline` diff --git a/Modules/Sources/UITestsFoundation/Screens/Editor/EditorPostSettings.swift b/Modules/Sources/UITestsFoundation/Screens/Editor/EditorPostSettings.swift index 161a58d3bb12..3cb566a8b73d 100644 --- a/Modules/Sources/UITestsFoundation/Screens/Editor/EditorPostSettings.swift +++ b/Modules/Sources/UITestsFoundation/Screens/Editor/EditorPostSettings.swift @@ -4,39 +4,39 @@ import XCTest public class EditorPostSettings: ScreenObject { private let settingsTableGetter: (XCUIApplication) -> XCUIElement = { - $0.tables["SettingsTable"] + $0.collectionViews["post_settings_form"].firstMatch } private let categoriesSectionGetter: (XCUIApplication) -> XCUIElement = { - $0.cells["Categories"] + $0.buttons["post_settings_categories"].firstMatch } private let tagsSectionGetter: (XCUIApplication) -> XCUIElement = { - $0.cells["Tags"] + $0.buttons["post_settings_tags"].firstMatch } private let publishDateButtonGetter: (XCUIApplication) -> XCUIElement = { - $0.staticTexts["Publish Date"] + $0.staticTexts["Publish Date"].firstMatch } private let dateSelectorGetter: (XCUIApplication) -> XCUIElement = { - $0.staticTexts["Immediately"] + $0.staticTexts["Immediately"].firstMatch } private let nextMonthButtonGetter: (XCUIApplication) -> XCUIElement = { - $0.buttons["Next Month"] + $0.buttons["Next Month"].firstMatch } private let monthLabelGetter: (XCUIApplication) -> XCUIElement = { - $0.buttons["Month"] + $0.buttons["Month"].firstMatch } private let firstCalendarDayButtonGetter: (XCUIApplication) -> XCUIElement = { $0.buttons.containing(.staticText, identifier: "1").element } - private let closeButtonGetter: (XCUIApplication) -> XCUIElement = { - $0.navigationBars.buttons["close"] + private let saveButtonGetter: (XCUIApplication) -> XCUIElement = { + $0.navigationBars.buttons["post_settings_save_button"].firstMatch } private let backButtonGetter: (XCUIApplication) -> XCUIElement? = { @@ -45,10 +45,9 @@ public class EditorPostSettings: ScreenObject { var categoriesSection: XCUIElement { categoriesSectionGetter(app) } var chooseFromMediaButton: XCUIElement { app.buttons["Choose from Media"].firstMatch } - var closeButton: XCUIElement { closeButtonGetter(app) } + var saveButton: XCUIElement { saveButtonGetter(app) } var backButton: XCUIElement? { backButtonGetter(app) } - var featuredImageCell: XCUIElement { app.cells["post_settings_featured_image_cell"].firstMatch } - var selectedFeaturedImage: XCUIElement { app.otherElements["featured_image_current_image"].firstMatch } + var selectedFeaturedImage: XCUIElement { app.images["featured_image_current_image_menu"].firstMatch } var firstCalendarDayButton: XCUIElement { firstCalendarDayButtonGetter(app) } var monthLabel: XCUIElement { monthLabelGetter(app) } var nextMonthButton: XCUIElement { nextMonthButtonGetter(app) } @@ -88,13 +87,13 @@ public class EditorPostSettings: ScreenObject { } public func removeFeatureImage() throws -> EditorPostSettings { - featuredImageCell.tap() + app.buttons["post_settings_featured_image_cell"].firstMatch.tap() app.buttons["featured_image_button_remove"].firstMatch.tap() return try EditorPostSettings() } public func setFeaturedImage() throws -> EditorPostSettings { - featuredImageCell.tap() + app.buttons["post_settings_featured_image_cell"].firstMatch.tap() chooseFromMediaButton.tap() try MediaPickerAlbumScreen() .selectImage(atIndex: 0) // Select latest uploaded image @@ -118,10 +117,13 @@ public class EditorPostSettings: ScreenObject { return try EditorPostSettings() } - @discardableResult - public func closePostSettings() throws -> BlockEditorScreen { - closeButton.tap() + public func closePostSettings() { + app.buttons["post_settings_cancel_button"].firstMatch.tap() + } + @discardableResult + public func savePostSettings() throws -> BlockEditorScreen { + saveButton.tap() return try BlockEditorScreen() } diff --git a/Modules/Sources/WordPressUI/Extensions/SwiftUI+Extensions.swift b/Modules/Sources/WordPressUI/Extensions/SwiftUI+Extensions.swift index 5e39a6ad8f8a..70f18a4748e5 100644 --- a/Modules/Sources/WordPressUI/Extensions/SwiftUI+Extensions.swift +++ b/Modules/Sources/WordPressUI/Extensions/SwiftUI+Extensions.swift @@ -4,22 +4,3 @@ import SwiftUI public extension EdgeInsets { static let zero = EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0) } - -private struct PresentingViewControllerKey: EnvironmentKey { - static let defaultValue = WeakEnvironmentValueWrapper() -} - -extension EnvironmentValues { - public var presentingViewController: UIViewController? { - get { - self[PresentingViewControllerKey.self].value ?? UIViewController.topViewController - } - set { - self[PresentingViewControllerKey.self].value = newValue - } - } -} - -private final class WeakEnvironmentValueWrapper { - weak var value: T? -} diff --git a/Modules/Sources/WordPressUI/Views/SectionHeader.swift b/Modules/Sources/WordPressUI/Views/SectionHeader.swift new file mode 100644 index 000000000000..928f0626f219 --- /dev/null +++ b/Modules/Sources/WordPressUI/Views/SectionHeader.swift @@ -0,0 +1,16 @@ +import SwiftUI +import DesignSystem + +public struct SectionHeader: View { + let title: String + + public init(_ title: String) { + self.title = title + } + + public var body: some View { + Text(title.uppercased()) + .font(.caption2).fontWeight(.medium) + .foregroundStyle(Color.secondary) + } +} diff --git a/RELEASE-NOTES.txt b/RELEASE-NOTES.txt index 3b53201efcfe..99e7606ee5f1 100644 --- a/RELEASE-NOTES.txt +++ b/RELEASE-NOTES.txt @@ -6,6 +6,7 @@ ----- * [**] Add new “Subscribers” screen that shows both your email and Reader subscribers [#24513] * [*] Improve search in TimeZone Picker for more accurate results [#24612] +* [*] Add new "Author Picker" for "Post Settings" with search and better design [#24621] * [*] Fix an issue with “Stats / Subscribers” sometimes not showing the latest email subscribers [#24513] * [*] Fix an issue with "Stats" / "Subscribers" / "Emails" showing html encoded characters [#24513] * [*] Add search to “Jetpack Activity List” and display actors and dates [#24597] diff --git a/Sources/Keystone/WordPress.h b/Sources/Keystone/WordPress.h index 6649cba7c660..a872eef38340 100644 --- a/Sources/Keystone/WordPress.h +++ b/Sources/Keystone/WordPress.h @@ -26,9 +26,7 @@ FOUNDATION_EXPORT const unsigned char WordPressVersionString[]; #import -#import #import -#import #import #import diff --git a/Sources/WordPressData/Blog+Extensions.swift b/Sources/WordPressData/Blog+Extensions.swift new file mode 100644 index 000000000000..1016feb31a4c --- /dev/null +++ b/Sources/WordPressData/Blog+Extensions.swift @@ -0,0 +1,75 @@ +import Foundation +import CoreData +import WordPressShared + +extension Blog { + + // MARK: - Post Formats + + /// Returns an array of post format keys sorted with "standard" first, then alphabetically + @objc public var sortedPostFormats: [String] { + guard let postFormats = postFormats as? [String: String], !postFormats.isEmpty else { + return [] + } + + var sortedFormats: [String] = [] + + // Add standard format first if it exists + if postFormats[PostFormatStandard] != nil { + sortedFormats.append(PostFormatStandard) + } + + // Add remaining formats sorted by their display names + let nonStandardFormats = postFormats + .filter { $0.key != PostFormatStandard } + .sorted { $0.value.localizedCaseInsensitiveCompare($1.value) == .orderedAscending } + .map { $0.key } + + sortedFormats.append(contentsOf: nonStandardFormats) + + return sortedFormats + } + + /// Returns an array of post format display names sorted with "Standard" first, then alphabetically + @objc public var sortedPostFormatNames: [String] { + guard let postFormats = postFormats as? [String: String] else { + return [] + } + return sortedPostFormats.compactMap { postFormats[$0] } + } + + /// Returns the default post format display text + @objc public var defaultPostFormatText: String? { + postFormatText(fromSlug: settings?.defaultPostFormat) + } + + // MARK: - Connections + + /// Returns an array of PublicizeConnection objects sorted by service name, then by external name + @objc public var sortedConnections: [PublicizeConnection] { + guard let connections = Array(connections ?? []) as? [PublicizeConnection] else { + return [] + } + return connections.sorted { lhs, rhs in + // First sort by service name (case insensitive, localized) + let serviceComparison = lhs.service.localizedCaseInsensitiveCompare(rhs.service) + if serviceComparison != .orderedSame { + return serviceComparison == .orderedAscending + } + // Then sort by external name (case insensitive) + return lhs.externalName.caseInsensitiveCompare(rhs.externalName) == .orderedAscending + } + } + + // MARK: - Roles + + /// Returns an array of roles sorted by order. + public var sortedRoles: [Role] { + guard let roles = Array(roles ?? []) as? [Role] else { + return [] + } + return roles.sorted { lhs, rhs in + (lhs.order?.intValue ?? 0) < (rhs.order?.intValue ?? 0) + } + } +} diff --git a/Sources/WordPressData/Objective-C/AbstractPost.m b/Sources/WordPressData/Objective-C/AbstractPost.m index 5a4ad2b632e9..7f34f36528c6 100644 --- a/Sources/WordPressData/Objective-C/AbstractPost.m +++ b/Sources/WordPressData/Objective-C/AbstractPost.m @@ -320,11 +320,6 @@ - (BOOL)isPrivateAtWPCom return self.blog.isPrivateAtWPCom; } -- (BOOL)isMultiAuthorBlog -{ - return self.blog.isMultiAuthor; -} - - (BOOL)isUploading { return self.remoteStatus == AbstractPostRemoteStatusPushing; diff --git a/Sources/WordPressData/Objective-C/BasePost.m b/Sources/WordPressData/Objective-C/BasePost.m index 4ae3f927e207..8786acb0261a 100644 --- a/Sources/WordPressData/Objective-C/BasePost.m +++ b/Sources/WordPressData/Objective-C/BasePost.m @@ -76,14 +76,6 @@ - (NSDate *)dateForDisplay return [self dateCreated]; } -- (NSString *)slugForDisplay -{ - if (self.wp_slug.length > 0) { - return self.wp_slug; - } - return self.suggested_slug; -} - - (BOOL)hasContent { BOOL titleIsEmpty = self.postTitle ? self.postTitle.isEmpty : YES; diff --git a/Sources/WordPressData/Objective-C/Blog.m b/Sources/WordPressData/Objective-C/Blog.m index 3a5cfdb919f0..1f51d830489d 100644 --- a/Sources/WordPressData/Objective-C/Blog.m +++ b/Sources/WordPressData/Objective-C/Blog.m @@ -281,56 +281,6 @@ - (NSArray *)sortedCategories return [[self.categories allObjects] sortedArrayUsingDescriptors:sortDescriptors]; } -- (NSArray *)sortedPostFormats -{ - if ([self.postFormats count] == 0) { - return @[]; - } - - NSMutableArray *sortedFormats = [NSMutableArray arrayWithCapacity:[self.postFormats count]]; - - if (self.postFormats[PostFormatStandard]) { - [sortedFormats addObject:PostFormatStandard]; - } - - NSArray *sortedNonStandardFormats = [[self.postFormats keysSortedByValueUsingSelector:@selector(localizedCaseInsensitiveCompare:)] wp_filter:^BOOL(id obj) { - return ![obj isEqual:PostFormatStandard]; - }]; - - [sortedFormats addObjectsFromArray:sortedNonStandardFormats]; - - return [NSArray arrayWithArray:sortedFormats]; -} - -- (NSArray *)sortedPostFormatNames -{ - return [[self sortedPostFormats] wp_map:^id(NSString *key) { - return self.postFormats[key]; - }]; -} - -- (NSArray *)sortedConnections -{ - NSSortDescriptor *sortServiceDescriptor = [[NSSortDescriptor alloc] initWithKey:@"service" - ascending:YES - selector:@selector(localizedCaseInsensitiveCompare:)]; - NSSortDescriptor *sortExternalNameDescriptor = [[NSSortDescriptor alloc] initWithKey:@"externalName" - ascending:YES - selector:@selector(caseInsensitiveCompare:)]; - NSArray *sortDescriptors = @[sortServiceDescriptor, sortExternalNameDescriptor]; - return [[self.connections allObjects] sortedArrayUsingDescriptors:sortDescriptors]; -} - -- (NSArray *)sortedRoles -{ - return [self.roles sortedArrayUsingDescriptors:@[[NSSortDescriptor sortDescriptorWithKey:@"order" ascending:YES]]]; -} - -- (NSString *)defaultPostFormatText -{ - return [self postFormatTextFromSlug:self.settings.defaultPostFormat]; -} - - (BOOL)hasMappedDomain { if (![self isHostedAtWPcom]) { return NO; diff --git a/Sources/WordPressData/Objective-C/include/AbstractPost.h b/Sources/WordPressData/Objective-C/include/AbstractPost.h index b31bf4fd4002..304aa8f5e8bd 100644 --- a/Sources/WordPressData/Objective-C/include/AbstractPost.h +++ b/Sources/WordPressData/Objective-C/include/AbstractPost.h @@ -86,7 +86,6 @@ typedef NS_ENUM(NSUInteger, AbstractPostRemoteStatus) { - (NSString *)authorNameForDisplay; - (NSString *)blavatarForDisplay; - (NSString *)dateStringForDisplay; -- (BOOL)isMultiAuthorBlog; - (BOOL)isPrivateAtWPCom; diff --git a/Sources/WordPressData/Objective-C/include/Blog.h b/Sources/WordPressData/Objective-C/include/Blog.h index cd8e03fd587c..710e44029bde 100644 --- a/Sources/WordPressData/Objective-C/include/Blog.h +++ b/Sources/WordPressData/Objective-C/include/Blog.h @@ -16,6 +16,8 @@ NS_ASSUME_NONNULL_BEGIN @class PageTemplateCategory; @class PublicizeInfo; @class BlobEntity; +@class PostCategory; +@class PublicizeConnection; extern NSString * const BlogEntityName; extern NSString * const PostFormatStandard; @@ -132,10 +134,10 @@ typedef NS_ENUM(NSInteger, SiteVisibility) { @property (nonatomic, strong, readwrite, nullable) NSNumber *hasOlderPosts; @property (nonatomic, strong, readwrite, nullable) NSNumber *hasOlderPages; @property (nonatomic, strong, readwrite, nullable) NSSet *posts; -@property (nonatomic, strong, readwrite, nullable) NSSet *categories; +@property (nonatomic, strong, readwrite, nullable) NSSet *categories; @property (nonatomic, strong, readwrite, nullable) NSSet *tags; @property (nonatomic, strong, readwrite, nullable) NSSet *comments; -@property (nonatomic, strong, readwrite, nullable) NSSet *connections; +@property (nonatomic, strong, readwrite, nullable) NSSet *connections; @property (nonatomic, strong, readwrite, nullable) NSSet *inviteLinks; @property (nonatomic, strong, readwrite, nullable) NSSet *domains; @property (nonatomic, strong, readwrite, nullable) NSSet *themes; @@ -202,10 +204,6 @@ typedef NS_ENUM(NSInteger, SiteVisibility) { // Readonly Properties -@property (nonatomic, weak, readonly, nullable) NSArray *sortedPostFormatNames; -@property (nonatomic, weak, readonly, nullable) NSArray *sortedPostFormats; -@property (nonatomic, weak, readonly, nullable) NSArray *sortedConnections; -@property (nonatomic, readonly, nullable) NSArray *sortedRoles; @property (nonatomic, strong, readonly, nullable) WordPressOrgXMLRPCApi *xmlrpcApi; @property (nonatomic, strong, readonly, nullable) WordPressOrgRestApi *selfHostedSiteRestApi; @property (nonatomic, weak, readonly, nullable) NSString *version; diff --git a/Sources/WordPressData/Objective-C/include/PostContentProvider.h b/Sources/WordPressData/Objective-C/include/PostContentProvider.h index a3d68ca2e442..ea2571ff79b9 100644 --- a/Sources/WordPressData/Objective-C/include/PostContentProvider.h +++ b/Sources/WordPressData/Objective-C/include/PostContentProvider.h @@ -12,6 +12,5 @@ - (NSString *)blogNameForDisplay; - (NSURL *)featuredImageURLForDisplay; - (NSURL *)authorURL; -- (NSString *)slugForDisplay; - (NSArray *)tagsForDisplay; @end diff --git a/Sources/WordPressData/Swift/AbstractPost.swift b/Sources/WordPressData/Swift/AbstractPost.swift index 42ada0b22fbd..cf593b386c2c 100644 --- a/Sources/WordPressData/Swift/AbstractPost.swift +++ b/Sources/WordPressData/Swift/AbstractPost.swift @@ -28,6 +28,15 @@ public extension AbstractPost { private static let deprecatedStatuses: Set = [.pushing, .failed, .local, .sync, .pushingMedia, .autoSaved] + /// Creates an array of tags by parsing a comma-separate list of tags. + static func makeTags(from tags: String) -> [String] { + tags + .trimmingCharacters(in: .whitespacesAndNewlines) + .components(separatedBy: ",") + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + } + // MARK: - Status /// Returns `true` is the post has one of the given statuses. @@ -92,13 +101,6 @@ public extension AbstractPost { } } - // MARK: - Misc - - /// A title describing the status. Ie.: "Public" or "Private" or "Password protected" - @objc var titleForVisibility: String { - PostVisibility(post: self).localizedTitle - } - /// Represent the supported properties used to sort posts. /// enum SortField { diff --git a/Sources/WordPressData/Swift/Media.swift b/Sources/WordPressData/Swift/Media.swift index 1ef9b5bd2d6b..ab15fbd16aec 100644 --- a/Sources/WordPressData/Swift/Media.swift +++ b/Sources/WordPressData/Swift/Media.swift @@ -107,3 +107,9 @@ private extension MediaType { } } } + +extension Media: Identifiable { + public var id: NSManagedObjectID { + objectID + } +} diff --git a/Sources/WordPressData/Swift/Page.swift b/Sources/WordPressData/Swift/Page.swift index b1999359fab9..7e877673ea30 100644 --- a/Sources/WordPressData/Swift/Page.swift +++ b/Sources/WordPressData/Swift/Page.swift @@ -45,9 +45,9 @@ public class Page: AbstractPost { @objc public var isSiteHomepage: Bool { guard let postID, - let homepageID = blog.homepagePageID, - let homepageType = blog.homepageType, - homepageType == .page else { + let homepageID = blog.homepagePageID, + let homepageType = blog.homepageType, + homepageType == .page else { return false } @@ -56,12 +56,29 @@ public class Page: AbstractPost { @objc public var isSitePostsPage: Bool { guard let postID, - let postsPageID = blog.homepagePostsPageID, - let homepageType = blog.homepageType, - homepageType == .page else { + let postsPageID = blog.homepagePostsPageID, + let homepageType = blog.homepageType, + homepageType == .page else { return false } return postsPageID == postID.intValue } + + // MARK: - Parent Page + + /// Returns the display text for the parent page + public static func parentPageText(in context: NSManagedObjectContext, parentID: NSNumber) -> String? { + guard parentID.intValue > 0 else { + return nil + } + let request = NSFetchRequest(entityName: Page.entityName()) + request.fetchLimit = 1 + request.predicate = NSPredicate(format: "postID == %@", parentID) + + guard let parent = try? context.fetch(request).first else { + return nil + } + return parent.titleForDisplay() + } } diff --git a/Sources/WordPressData/Swift/Post.swift b/Sources/WordPressData/Swift/Post.swift index 67228f0e230b..350fb5976f0d 100644 --- a/Sources/WordPressData/Swift/Post.swift +++ b/Sources/WordPressData/Swift/Post.swift @@ -65,21 +65,6 @@ public class Post: AbstractPost { // MARK: - Categories - /// Returns categories as a comma-separated list - /// - @objc public func categoriesText() -> String { - - guard let allStrings = categories?.map({ return $0.categoryName as String }) else { - return "" - } - - let orderedStrings = allStrings.sorted { (categoryName1, categoryName2) -> Bool in - return categoryName1.localizedCaseInsensitiveCompare(categoryName2) == .orderedAscending - } - - return orderedStrings.joined(separator: ", ") - } - /// Set the categories for a post /// /// - Parameter categoryNames: a `NSArray` with the names of the categories for this post. If @@ -119,7 +104,7 @@ public class Post: AbstractPost { let isKeyringEntryDisabled = disabledPublicizeConnections?[keyringID]?[Constants.publicizeValueKey] == Constants.publicizeDisabledValue // try to check in case there's an entry for the PublicizeConnection that's keyed by the connectionID. - guard let connections = blog.connections as? Set, + guard let connections = blog.connections, let connection = connections.first(where: { $0.keyringConnectionID == keyringID }), let existingValue = disabledPublicizeConnections?[connection.connectionID]?[Constants.publicizeValueKey] else { // fall back to keyringID if there is no such entry with the connectionID. @@ -130,10 +115,10 @@ public class Post: AbstractPost { return isConnectionEntryDisabled || isKeyringEntryDisabled } - @objc public func enablePublicizeConnectionWithKeyringID(_ keyringID: NSNumber) { + public func enablePublicizeConnectionWithKeyringID(_ keyringID: NSNumber) { // if there's another entry keyed by connectionID references to the same connection, // we need to make sure that the values are kept in sync. - if let connections = blog.connections as? Set, + if let connections = blog.connections, let connection = connections.first(where: { $0.keyringConnectionID == keyringID }), let _ = disabledPublicizeConnections?[connection.connectionID] { enablePublicizeConnection(keyedBy: connection.connectionID) @@ -142,10 +127,10 @@ public class Post: AbstractPost { enablePublicizeConnection(keyedBy: keyringID) } - @objc public func disablePublicizeConnectionWithKeyringID(_ keyringID: NSNumber) { + public func disablePublicizeConnectionWithKeyringID(_ keyringID: NSNumber) { // if there's another entry keyed by connectionID references to the same connection, // we need to make sure that the values are kept in sync. - if let connections = blog.connections as? Set, + if let connections = blog.connections, let connectionID = connections.first(where: { $0.keyringConnectionID == keyringID })?.connectionID, let _ = disabledPublicizeConnections?[connectionID] { disablePublicizeConnection(keyedBy: connectionID) diff --git a/Sources/WordPressData/Swift/PostHelper+JetpackSocial.swift b/Sources/WordPressData/Swift/PostHelper+JetpackSocial.swift index 28001063c11a..9b7ee14d11cd 100644 --- a/Sources/WordPressData/Swift/PostHelper+JetpackSocial.swift +++ b/Sources/WordPressData/Swift/PostHelper+JetpackSocial.swift @@ -43,7 +43,7 @@ extension PostHelper { // the connectionID, and return its keyringID. let entryConnectionID = Int(key.removingPrefix(SkipPrefix.connection.rawValue)) - guard let connections = post.blog.connections as? Set, + guard let connections = post.blog.connections, let connectionID = entryConnectionID, let connection = connections.first(where: { $0.connectionID.intValue == connectionID }) else { /// Otherwise, fall back to the connectionID extracted from the metadata key. @@ -94,7 +94,7 @@ extension PostHelper { // Try to add a key with the new format ONLY if the metadata hasn't been synced to the remote. let metadataKeyValue: String = { guard entry[Keys.publicizeIdKey] == nil, - let connections = post.blog.connections as? Set, + let connections = post.blog.connections, let connection = connections.first(where: { $0.keyringConnectionID == keyringID }) else { // Fall back to the old keyring format. return "\(SkipPrefix.keyring.rawValue)\(keyringID)" diff --git a/Sources/WordPressData/Swift/PostVisibility.swift b/Sources/WordPressData/Swift/PostVisibility.swift index 5f6be57a0e0a..afddec74d436 100644 --- a/Sources/WordPressData/Swift/PostVisibility.swift +++ b/Sources/WordPressData/Swift/PostVisibility.swift @@ -9,7 +9,7 @@ public enum PostVisibility: Identifiable, CaseIterable { self.init(status: post.status ?? .draft, password: post.password) } - init(status: AbstractPost.Status, password: String?) { + public init(status: AbstractPost.Status, password: String?) { if let password, !password.isEmpty { self = .protected } else if status == .publishPrivate { diff --git a/Sources/WordPressData/Swift/ReaderTagTopic.swift b/Sources/WordPressData/Swift/ReaderTagTopic.swift index bc874fed1b5a..20a8da233481 100644 --- a/Sources/WordPressData/Swift/ReaderTagTopic.swift +++ b/Sources/WordPressData/Swift/ReaderTagTopic.swift @@ -12,13 +12,6 @@ open class ReaderTagTopic: ReaderAbstractTopic { return "tag" } - // MARK: - Computed Properties - - /// The `slug` property is URL encoded. Use this property for display instead. - public var slugForDisplay: String? { - return slug.removingPercentEncoding - } - // MARK: - Logged Out Helpers /// The tagID used if an interest was added locally and not sync'd with the server diff --git a/Sources/WordPressData/Swift/RemotePostCreateParameters+Helpers.swift b/Sources/WordPressData/Swift/RemotePostCreateParameters+Helpers.swift index 33da60bcd48b..2459909c6cc7 100644 --- a/Sources/WordPressData/Swift/RemotePostCreateParameters+Helpers.swift +++ b/Sources/WordPressData/Swift/RemotePostCreateParameters+Helpers.swift @@ -26,7 +26,7 @@ extension RemotePostCreateParameters { case let post as Post: format = post.postFormat isSticky = post.isStickyPost - tags = makeTags(from: post.tags ?? "") + tags = AbstractPost.makeTags(from: post.tags ?? "") categoryIDs = (post.categories ?? []).compactMap { $0.categoryID?.intValue } @@ -44,11 +44,3 @@ extension RemotePostCreateParameters { } } } - -private func makeTags(from tags: String) -> [String] { - tags - .trimmingCharacters(in: .whitespacesAndNewlines) - .components(separatedBy: ",") - .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } - .filter { !$0.isEmpty } -} diff --git a/Tests/KeystoneTests/Tests/Features/Posts/PostSettingsTests.swift b/Tests/KeystoneTests/Tests/Features/Posts/PostSettingsTests.swift new file mode 100644 index 000000000000..d8cf3a014390 --- /dev/null +++ b/Tests/KeystoneTests/Tests/Features/Posts/PostSettingsTests.swift @@ -0,0 +1,279 @@ +import Testing +import Foundation +import CoreData +@testable import WordPress +@testable import WordPressData + +@MainActor +@Suite("PostSettings Tests") +struct PostSettingsTests { + + // MARK: - apply(to:) Tests + + @Test("Applies basic properties to post") + func testApplyBasicProperties() throws { + // Given + let context = ContextManager.forTesting().mainContext + let blog = BlogBuilder(context).build() + let post = PostBuilder(context, blog: blog).build() + + var settings = PostSettings(from: post) + settings.slug = "new-slug" + settings.status = .publish + settings.publishDate = Date(timeIntervalSince1970: 1000) + settings.password = "secret" + + // When + settings.apply(to: post) + + // Then + #expect(post.wp_slug == "new-slug") + #expect(post.status == .publish) + #expect(post.dateCreated == Date(timeIntervalSince1970: 1000)) + #expect(post.password == "secret") + } + + @Test("Applies author changes to post") + func testApplyAuthorChanges() throws { + // Given + let context = ContextManager.forTesting().mainContext + let blog = BlogBuilder(context).build() + let post = PostBuilder(context, blog: blog).build() + + var settings = PostSettings(from: post) + settings.author = PostSettings.Author( + id: 123, + displayName: "John Doe", + avatarURL: URL(string: "https://example.com/avatar.jpg") + ) + + // When + settings.apply(to: post) + + // Then + #expect(post.authorID == NSNumber(value: 123)) + #expect(post.author == "John Doe") + #expect(post.authorAvatarURL == "https://example.com/avatar.jpg") + } + + @Test("Applies featured image changes to post") + func testApplyFeaturedImageChanges() throws { + // Given + let context = ContextManager.forTesting().mainContext + let blog = BlogBuilder(context).build() + let post = PostBuilder(context, blog: blog).build() + + var settings = PostSettings(from: post) + settings.featuredImageID = 456 + + // When + settings.apply(to: post) + + // Then + #expect(post.featuredImage?.mediaID == NSNumber(value: 456)) + } + + @Test("Removes featured image when ID is nil") + func testRemoveFeaturedImage() throws { + // Given + let context = ContextManager.forTesting().mainContext + let blog = BlogBuilder(context).build() + let post = PostBuilder(context, blog: blog).build() + let media = Media(context: context) + media.mediaID = NSNumber(value: 789) + post.featuredImage = media + + var settings = PostSettings(from: post) + settings.featuredImageID = nil + + // When + settings.apply(to: post) + + // Then + #expect(post.featuredImage == nil) + } + + @Test("Applies categories and tags to post") + func testApplyCategoriesAndTags() throws { + // Given + let context = ContextManager.forTesting().mainContext + let blog = BlogBuilder(context).build() + let post = PostBuilder(context, blog: blog).build() + + // Create test categories + let category1 = PostCategory(context: context) + category1.categoryID = NSNumber(value: 1) + category1.categoryName = "Tech" + category1.blog = blog + + let category2 = PostCategory(context: context) + category2.categoryID = NSNumber(value: 2) + category2.categoryName = "News" + category2.blog = blog + + blog.categories = Set([category1, category2]) + + var settings = PostSettings(from: post) + settings.categoryIDs = Set([1, 2]) + settings.tags = "swift, ios, testing" + + // When + settings.apply(to: post) + + // Then + #expect(post.categories?.count == 2) + #expect(post.categories?.contains(category1) == true) + #expect(post.categories?.contains(category2) == true) + #expect(post.tags == "swift, ios, testing") + } + + @Test("Only updates changed properties") + func testOnlyUpdatesChangedProperties() throws { + // Given + let context = ContextManager.forTesting().mainContext + let blog = BlogBuilder(context).build() + let post = PostBuilder(context, blog: blog).build() + + let originalSlug = "original-slug" + post.wp_slug = originalSlug + post.status = .draft + + var settings = PostSettings(from: post) + // Only change status, not slug + settings.status = .publish + + // When + settings.apply(to: post) + + // Then + #expect(post.wp_slug == originalSlug) // Unchanged + #expect(post.status == .publish) // Changed + } + + // MARK: - makeUpdateParameters Tests + + @Test("Creates update parameters for changed properties") + func testMakeUpdateParametersWithChanges() throws { + // Given + let context = ContextManager.forTesting().mainContext + let blog = BlogBuilder(context).build() + let post = PostBuilder(context, blog: blog).build() + + post.postTitle = "Original Title" + post.content = "Original Content" + post.wp_slug = "original-slug" + + var settings = PostSettings(from: post) + settings.slug = "updated-slug" + + // When + let parameters = settings.makeUpdateParameters(from: post) + + // Then + #expect(parameters.slug == "updated-slug") + #expect(parameters.title == nil) // Title wasn't changed via settings + #expect(parameters.content == nil) // Content wasn't changed via settings + } + + @Test("Creates empty parameters when no changes") + func testMakeUpdateParametersWithNoChanges() throws { + // Given + let context = ContextManager.forTesting().mainContext + let blog = BlogBuilder(context).build() + let post = PostBuilder(context, blog: blog).build() + + let settings = PostSettings(from: post) + + // When + let parameters = settings.makeUpdateParameters(from: post) + + // Then + // Check that parameters has no significant changes + #expect(parameters.status == nil) + #expect(parameters.slug == nil) + #expect(parameters.date == nil) + #expect(parameters.authorID == nil) + } + + // MARK: - Text Generation Tests + + @Test("Generates categories text correctly") + func testMakeCategoriesText() throws { + // Given + let context = ContextManager.forTesting().mainContext + let blog = BlogBuilder(context).build() + let post = PostBuilder(context, blog: blog).build() + + // Create test categories + let category1 = PostCategory(context: context) + category1.categoryID = NSNumber(value: 1) + category1.categoryName = "Technology" + category1.blog = blog + + let category2 = PostCategory(context: context) + category2.categoryID = NSNumber(value: 2) + category2.categoryName = "Apple" + category2.blog = blog + + blog.categories = Set([category1, category2]) + + var settings = PostSettings(from: post) + settings.categoryIDs = Set([1, 2]) + + // When + let categoryNames = settings.getCategoryNames(for: post) + + // Then + #expect(categoryNames == ["Apple", "Technology"]) // Alphabetically sorted + } + + @Test("Generates empty categories text for pages") + func testMakeCategoriesTextForPage() throws { + // Given + let context = ContextManager.forTesting().mainContext + let page = PageBuilder(context).build() + + var settings = PostSettings(from: page) + settings.categoryIDs = Set([1, 2]) + + // When + let categoryNames = settings.getCategoryNames(for: page) + + // Then + #expect(categoryNames == []) + } + + @Test("Generates tags text correctly") + func testMakeTagsText() throws { + // Given + let context = ContextManager.forTesting().mainContext + let blog = BlogBuilder(context).build() + let post = PostBuilder(context, blog: blog).build() + + var settings = PostSettings(from: post) + settings.tags = "swift, ios, testing" + + // When + let tagsText = settings.tags + + // Then + #expect(tagsText == "swift, ios, testing") + } + + @Test("Generates empty tags text") + func testMakeTagsTextEmpty() throws { + // Given + let context = ContextManager.forTesting().mainContext + let blog = BlogBuilder(context).build() + let post = PostBuilder(context, blog: blog).build() + + var settings = PostSettings(from: post) + settings.tags = "" + + // When + let tagsText = settings.tags + + // Then + #expect(tagsText == "") + } +} diff --git a/Tests/KeystoneTests/Tests/Features/Posts/PublishSettingsControllerTests.swift b/Tests/KeystoneTests/Tests/Features/Posts/PublishSettingsControllerTests.swift deleted file mode 100644 index 5986efea72ed..000000000000 --- a/Tests/KeystoneTests/Tests/Features/Posts/PublishSettingsControllerTests.swift +++ /dev/null @@ -1,86 +0,0 @@ -import XCTest -@testable import WordPress -@testable import WordPressData - -class PublishSettingsViewControllerTests: CoreDataTestCase { - - func testViewModelDateScheduled() { - let testDate = Date().addingTimeInterval(5000) - - let post = PostBuilder(mainContext).with(dateCreated: testDate).drafted().withRemote().build() - - var viewModel = PublishSettingsViewModel(post: post) - XCTAssertEqual(viewModel.date, testDate, "Date should exist in view model") - - if case PublishSettingsViewModel.State.scheduled(_) = viewModel.state { - // Success - } else { - XCTFail("View model should be scheduled") - } - - viewModel.setDate(testDate) - - if case PublishSettingsViewModel.State.scheduled(_) = viewModel.state { - // Success - } else { - XCTFail("View model should be scheduled instead of \(viewModel.state)") - } - } - - func testViewModelDateImmediately() { - let testDate = Date() - - let post = PostBuilder(mainContext).drafted().withRemote().build() - - var viewModel = PublishSettingsViewModel(post: post) - XCTAssertNil(viewModel.date, "Date should not exist in view model") - - if case PublishSettingsViewModel.State.immediately = viewModel.state { - // Success - } else { - XCTFail("View model should be immediately instead of \(viewModel.state)") - } - - viewModel.setDate(testDate) - - if case PublishSettingsViewModel.State.published(_) = viewModel.state { - // Success - } else { - XCTFail("View model should be published instead of \(viewModel.state)") - } - } - - func testViewModelDatePublished() { - let testDate = Date() - - let post = PostBuilder(mainContext).with(dateCreated: testDate).published().withRemote().build() - - var viewModel = PublishSettingsViewModel(post: post) - XCTAssertEqual(viewModel.date, testDate, "Date should exist in view model") - - if case PublishSettingsViewModel.State.published(_) = viewModel.state { - // Success - } else { - XCTFail("View model should be published instead of \(viewModel.state)") - } - - viewModel.setDate(testDate) - - if case PublishSettingsViewModel.State.published(_) = viewModel.state { - // Success - } else { - XCTFail("View model should be published instead of \(viewModel.state)") - } - } -} - -extension PublishSettingsViewControllerTests { - // MARK: - Private Helpers - fileprivate func newSettings() -> BlogSettings { - let context = contextManager.mainContext - let name = BlogSettings.classNameWithoutNamespaces() - let entity = NSEntityDescription.insertNewObject(forEntityName: name, into: context) - - return entity as! BlogSettings - } -} diff --git a/Tests/KeystoneTests/Tests/Models/PostTests.swift b/Tests/KeystoneTests/Tests/Models/PostTests.swift index 317467ef6633..3b61bc0d6a7e 100644 --- a/Tests/KeystoneTests/Tests/Models/PostTests.swift +++ b/Tests/KeystoneTests/Tests/Models/PostTests.swift @@ -25,24 +25,6 @@ class PostTests: CoreDataTestCase { return category } - func testThatNoCategoriesReturnEmptyStringWhenCallingCategoriesText() { - let post = newTestPost() - let categoriesText = post.categoriesText() - - XCTAssertEqual(categoriesText, "") - } - - func testThatSomeCategoriesReturnAListWhenCallingCategoriesText() { - - let post = newTestPost() - - post.categories = [newTestPostCategory("1"), newTestPostCategory("2"), newTestPostCategory("3")] - - let categoriesText = post.categoriesText() - - XCTAssertEqual(categoriesText, "1, 2, 3") - } - func testSetCategoriesFromNamesWithTwoCategories() { let blog = newTestBlog() let post = newTestPost() diff --git a/Tests/KeystoneTests/Tests/Services/SharingServiceTests.swift b/Tests/KeystoneTests/Tests/Services/SharingServiceTests.swift index 67b842db8bd0..6ca7bfa6be56 100644 --- a/Tests/KeystoneTests/Tests/Services/SharingServiceTests.swift +++ b/Tests/KeystoneTests/Tests/Services/SharingServiceTests.swift @@ -70,7 +70,7 @@ class SharingServiceTests: CoreDataTestCase { } // Then - let connections = try XCTUnwrap(blog.connections as? Set) + let connections = try XCTUnwrap(blog.connections) // the one with ID `1002` should be skipped since it's an unshared private connection from another user. XCTAssertEqual(connections.count, 2) diff --git a/WordPress/Classes/Plugins/Views/PluginDetailsView.swift b/WordPress/Classes/Plugins/Views/PluginDetailsView.swift index aeb7c931d747..1bbcb8c55f6c 100644 --- a/WordPress/Classes/Plugins/Views/PluginDetailsView.swift +++ b/WordPress/Classes/Plugins/Views/PluginDetailsView.swift @@ -305,7 +305,7 @@ struct PluginDetailsView: View { .listRowSeparator(.hidden) .fullScreenCover(item: $tappedScreenshot) { if let viewController = self.lightbox(screenshot: $0) { - LightboxView(viewController: viewController) + WrappedViewController(viewController: viewController) .ignoresSafeArea() } else { EmptyView() @@ -584,7 +584,7 @@ extension Ratings { } } -private struct LightboxView: UIViewControllerRepresentable { +private struct WrappedViewController: UIViewControllerRepresentable { let viewController: UIViewController func makeUIViewController(context: Context) -> UIViewController { diff --git a/WordPress/Classes/System/WordPress-Bridging-Header.h b/WordPress/Classes/System/WordPress-Bridging-Header.h index 4108eb25fe20..d76db2d76094 100644 --- a/WordPress/Classes/System/WordPress-Bridging-Header.h +++ b/WordPress/Classes/System/WordPress-Bridging-Header.h @@ -18,9 +18,7 @@ #import "NSObject+Helpers.h" -#import "PageSettingsViewController.h" #import "PostCategoryService.h" -#import "PostSettingsViewController.h" #import "PostTagService.h" #import "ReaderPostService.h" diff --git a/WordPress/Classes/ViewRelated/Blog/Sharing/WPStyleGuide+Sharing.swift b/WordPress/Classes/ViewRelated/Blog/Sharing/WPStyleGuide+Sharing.swift index bed0a181220b..145ff1700ced 100644 --- a/WordPress/Classes/ViewRelated/Blog/Sharing/WPStyleGuide+Sharing.swift +++ b/WordPress/Classes/ViewRelated/Blog/Sharing/WPStyleGuide+Sharing.swift @@ -43,7 +43,7 @@ extension WPStyleGuide { /// /// - Returns: A template UIImage that can be tinted by a UIImageView's tintColor property. /// - @objc public class func iconForService(_ service: NSString) -> UIImage { + public class func iconForService(_ service: NSString) -> UIImage { let name = service.lowercased.replacingOccurrences(of: "_", with: "-") var iconName: String @@ -68,7 +68,7 @@ extension WPStyleGuide { return image!.withRenderingMode(.alwaysTemplate) } - @objc public class func socialIcon(for service: NSString) -> UIImage { + public class func socialIcon(for service: NSString) -> UIImage { UIImage(named: "icon-\(service)") ?? iconForService(service) } } diff --git a/WordPress/Classes/ViewRelated/Gutenberg/GutenbergViewController.swift b/WordPress/Classes/ViewRelated/Gutenberg/GutenbergViewController.swift index 8462533307dd..7f31dd581d12 100644 --- a/WordPress/Classes/ViewRelated/Gutenberg/GutenbergViewController.swift +++ b/WordPress/Classes/ViewRelated/Gutenberg/GutenbergViewController.swift @@ -11,7 +11,7 @@ import AutomatticTracks import Combine import ImagePlayground -class GutenbergViewController: UIViewController, PostEditor, FeaturedImageDelegate, PublishingEditor { +class GutenbergViewController: UIViewController, PostEditor, PublishingEditor { let errorDomain: String = "GutenbergViewController.errorDomain" enum RequestHTMLReason { diff --git a/WordPress/Classes/ViewRelated/Me/Me Main/MeViewController.swift b/WordPress/Classes/ViewRelated/Me/Me Main/MeViewController.swift index 3489b1bb72f7..d94590ac3737 100644 --- a/WordPress/Classes/ViewRelated/Me/Me Main/MeViewController.swift +++ b/WordPress/Classes/ViewRelated/Me/Me Main/MeViewController.swift @@ -151,6 +151,18 @@ public class MeViewController: UITableViewController { action: pushAccountSettings(), accessibilityIdentifier: "accountSettings") + let domains = NavigationItemRow( + title: AllDomainsListViewController.Strings.title, + icon: UIImage(named: "wpl-globe")?.withRenderingMode(.alwaysTemplate), + tintColor: .label, + accessoryType: accessoryType, + action: { [weak self] action in + self?.showOrPushController(AllDomainsListViewController()) + WPAnalytics.track(.meDomainsTapped) + }, + accessibilityIdentifier: "myDomains" + ) + let helpAndSupportIndicator = IndicatorNavigationItemRow( title: RowTitles.support, icon: UIImage(named: "wpl-help")?.withRenderingMode(.alwaysTemplate), @@ -187,7 +199,9 @@ public class MeViewController: UITableViewController { if shouldShowQRLoginRow { loggedInRows.append(qrLogin) } - + if BuildSettings.current.brand == .jetpack, RemoteFeatureFlag.domainManagement.enabled() && !isSidebarModeEnabled { + loggedInRows.append(domains) + } rows = loggedInRows + rows } return rows @@ -195,23 +209,6 @@ public class MeViewController: UITableViewController { ImmuTableSection(rows: [helpAndSupportIndicator]), ]) - if BuildSettings.current.brand == .jetpack, RemoteFeatureFlag.domainManagement.enabled() && loggedIn && !isSidebarModeEnabled { - sections.append(.init(rows: [ - NavigationItemRow( - title: AllDomainsListViewController.Strings.title, - icon: UIImage(named: "wpl-globe")?.withRenderingMode(.alwaysTemplate), - tintColor: .label, - accessoryType: accessoryType, - action: { [weak self] action in - self?.showOrPushController(AllDomainsListViewController()) - WPAnalytics.track(.meDomainsTapped) - }, - accessibilityIdentifier: "myDomains" - ) - ]) - ) - } - sections.append( ImmuTableSection(rows: [ ButtonRow( diff --git a/WordPress/Classes/ViewRelated/Media/Lightbox/LightboxView.swift b/WordPress/Classes/ViewRelated/Media/Lightbox/LightboxView.swift new file mode 100644 index 000000000000..b7ec0d7b3b8c --- /dev/null +++ b/WordPress/Classes/ViewRelated/Media/Lightbox/LightboxView.swift @@ -0,0 +1,18 @@ +import SwiftUI +import WordPressData + +/// A SwiftUI wrapper for LightboxViewController +struct LightboxView: UIViewControllerRepresentable { + let media: Media + var thumbnail: UIImage? + + func makeUIViewController(context: Context) -> LightboxViewController { + let controller = LightboxViewController(media: media) + controller.thumbnail = thumbnail + return controller + } + + func updateUIViewController(_ uiViewController: LightboxViewController, context: Context) { + // No updates needed + } +} diff --git a/WordPress/Classes/ViewRelated/Media/MediaPicker/MediaPicker.swift b/WordPress/Classes/ViewRelated/Media/MediaPicker/MediaPicker.swift index b17fc7f3368f..cd5fb43356ab 100644 --- a/WordPress/Classes/ViewRelated/Media/MediaPicker/MediaPicker.swift +++ b/WordPress/Classes/ViewRelated/Media/MediaPicker/MediaPicker.swift @@ -2,14 +2,11 @@ import UIKit import SwiftUI import WordPressData import WordPressUI +import WordPressShared import Photos import PhotosUI -/// A media picker menu. -/// -/// - note: Use `.environment(\.presentingViewController, <#vc#>)` to pass the -/// presenting view controller. If not provided, the current top view controller -/// is used. +/// A media picker menu struct MediaPicker: View { var configuration = MediaPickerConfiguration() var onSelection: ((MediaPickerSelection) -> Void)? @@ -18,8 +15,6 @@ struct MediaPicker: View { @StateObject private var viewModel = MediaPickerViewModel() - @Environment(\.presentingViewController) var presentingViewController - var body: some View { Menu { menu @@ -45,7 +40,6 @@ struct MediaPicker: View { private func makeActions() -> [UIAction] { let menu = MediaPickerMenu( - viewController: presentingViewController ?? UIViewController(), filter: configuration.filter, isMultipleSelectionEnabled: configuration.isMultipleSelectionEnabled ) diff --git a/WordPress/Classes/ViewRelated/Media/MediaPicker/Menu/MediaPickerMenu.swift b/WordPress/Classes/ViewRelated/Media/MediaPicker/Menu/MediaPickerMenu.swift index 20a918cfd9eb..c4e2f8ddafdb 100644 --- a/WordPress/Classes/ViewRelated/Media/MediaPicker/Menu/MediaPickerMenu.swift +++ b/WordPress/Classes/ViewRelated/Media/MediaPicker/Menu/MediaPickerMenu.swift @@ -4,11 +4,16 @@ import WordPressData /// A convenience API for creating actions for picking media from different /// source supported by the app: Photos library, Camera, Media library. struct MediaPickerMenu { - weak var presentingViewController: UIViewController? + var presentingViewController: UIViewController? { + _presentingViewController ?? UIViewController.topViewController + } + var filter: MediaFilter? var isMultipleSelectionEnabled: Bool var initialSelection: [Media] + private weak var _presentingViewController: UIViewController? + enum MediaFilter { case images case videos @@ -21,11 +26,11 @@ struct MediaPickerMenu { /// - filter: By default, `nil` – allow all content types. /// - isMultipleSelectionEnabled: By default, `false`. /// - initialSelection: By default, `[]`. - init(viewController: UIViewController, + init(viewController: UIViewController? = nil, filter: MediaFilter? = nil, isMultipleSelectionEnabled: Bool = false, initialSelection: [Media] = []) { - self.presentingViewController = viewController + self._presentingViewController = viewController self.filter = filter self.isMultipleSelectionEnabled = isMultipleSelectionEnabled self.initialSelection = initialSelection diff --git a/WordPress/Classes/ViewRelated/Pages/Controllers/ParentPageSettingsViewController.swift b/WordPress/Classes/ViewRelated/Pages/Controllers/ParentPageSettingsViewController.swift index af5bcca3fb31..3142ba9e3ce1 100644 --- a/WordPress/Classes/ViewRelated/Pages/Controllers/ParentPageSettingsViewController.swift +++ b/WordPress/Classes/ViewRelated/Pages/Controllers/ParentPageSettingsViewController.swift @@ -51,6 +51,9 @@ class ParentPageSettingsViewController: UIViewController { private var filteredRows: [ImmuTableSection]! private var selectedPage: Page! + /// Called when the parent page selection changes + var onSelectionChanged: ((Page?) -> Void)? + override func viewDidLoad() { super.viewDidLoad() @@ -205,7 +208,16 @@ extension ParentPageSettingsViewController: UITableViewDelegate { guard let row = sections[indexPath.section].rows[indexPath.row] as? Row else { return } - selectedPage.parentID = row.page?.postID + + // Call the new closure if available + onSelectionChanged?(row.page) + + // Maintain backward compatibility: update the page's parentID directly + // if onSelectionChanged is not set + if onSelectionChanged == nil { + selectedPage.parentID = row.page?.postID + } + tableView.reloadData() } } diff --git a/WordPress/Classes/ViewRelated/Pages/PageSettingsViewController.h b/WordPress/Classes/ViewRelated/Pages/PageSettingsViewController.h deleted file mode 100644 index c78d0f33603e..000000000000 --- a/WordPress/Classes/ViewRelated/Pages/PageSettingsViewController.h +++ /dev/null @@ -1,5 +0,0 @@ -#import "PostSettingsViewController.h" - -@interface PageSettingsViewController : PostSettingsViewController - -@end diff --git a/WordPress/Classes/ViewRelated/Pages/PageSettingsViewController.m b/WordPress/Classes/ViewRelated/Pages/PageSettingsViewController.m deleted file mode 100644 index 8734ed14de5f..000000000000 --- a/WordPress/Classes/ViewRelated/Pages/PageSettingsViewController.m +++ /dev/null @@ -1,29 +0,0 @@ -#import "PageSettingsViewController.h" -#import "PostSettingsViewController_Internal.h" -#import "WordPress-Swift.h" -@interface PageSettingsViewController () - -@end - -@implementation PageSettingsViewController - -- (void)configureSections -{ - self.sections = @[ - @(PostSettingsSectionMeta), - @(PostSettingsSectionFeaturedImage), - @(PostSettingsSectionMoreOptions), - @(PostSettingsSectionPageAttributes) - ]; -} - -- (Page *)page -{ - if ([self.apost isKindOfClass:[Page class]]) { - return (Page *)self.apost; - } - - return nil; -} - -@end diff --git a/WordPress/Classes/ViewRelated/People/Controllers/PersonViewController.swift b/WordPress/Classes/ViewRelated/People/Controllers/PersonViewController.swift index 8c606f2d4c16..477e6b31d227 100644 --- a/WordPress/Classes/ViewRelated/People/Controllers/PersonViewController.swift +++ b/WordPress/Classes/ViewRelated/People/Controllers/PersonViewController.swift @@ -157,7 +157,7 @@ final class PersonViewController: UITableViewController { return } - roleViewController.roles = blog.sortedRoles?.map({ $0.toUnmanaged() }) ?? [] + roleViewController.roles = blog.sortedRoles.map({ $0.toUnmanaged() }) ?? [] roleViewController.selectedRole = person.role roleViewController.onChange = { [weak self] newRole in self?.updateUserRole(newRole) diff --git a/WordPress/Classes/ViewRelated/Post/ParentPagePicker.swift b/WordPress/Classes/ViewRelated/Post/ParentPagePicker.swift new file mode 100644 index 000000000000..b456042814a4 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Post/ParentPagePicker.swift @@ -0,0 +1,103 @@ +import SwiftUI +import CoreData +import WordPressData +import WordPressShared +import WordPressUI + +@MainActor +struct ParentPagePicker: View { + private let blog: Blog + private let currentPage: Page + private let onSelection: (Page?) -> Void + + @State private var isLoading = true + @State private var pages: [Page]? + @State private var error: Error? + + init(blog: Blog, currentPage: Page, onSelection: @escaping (Page?) -> Void) { + self.blog = blog + self.currentPage = currentPage + self.onSelection = onSelection + } + + var body: some View { + Group { + if let pages { + ParentPageSettingsViewControllerWrapper( + pages: pages, + selectedPage: currentPage, + onSelection: onSelection + ) + .ignoresSafeArea() + } else { + ProgressView() + } + } + .navigationTitle(Strings.title) + .navigationBarTitleDisplayMode(.inline) + .task { + await loadPages() + } + } + + private func loadPages() async { + do { + let request = NSFetchRequest(entityName: Page.entityName()) + let filter = PostListFilter.publishedFilter() + request.predicate = filter.predicate(for: blog, author: .everyone) + request.sortDescriptors = filter.sortDescriptors + + let context = ContextManager.shared.mainContext + var pages = try await PostRepository().buildPageTree(request: request) + .map { pageID, hierarchyIndex in + let page = try context.existingObject(with: pageID) + page.hierarchyIndex = hierarchyIndex + return page + } + + // Remove the current page from the list (can't be its own parent) + if let index = pages.firstIndex(of: currentPage) { + pages = pages.remove(from: index) + } + + self.pages = pages + } catch { + wpAssertionFailure("Failed to fetch pages", userInfo: ["error": "\(error)"]) // This should never happen + } + } +} + +// MARK: - UIViewControllerRepresentable Wrapper + +private struct ParentPageSettingsViewControllerWrapper: UIViewControllerRepresentable { + let pages: [Page] + let selectedPage: Page + let onSelection: (Page?) -> Void + + func makeUIViewController(context: Context) -> ParentPageSettingsViewController { + guard let viewController = ParentPageSettingsViewController.make( + with: pages, + selectedPage: selectedPage + ) as? ParentPageSettingsViewController else { + fatalError("Expected ParentPageSettingsViewController") + } + viewController.onSelectionChanged = { selectedParentPage in + onSelection(selectedParentPage) + } + return viewController + } + + func updateUIViewController(_ uiViewController: ParentPageSettingsViewController, context: Context) { + // No updates needed + } +} + +// MARK: - Localized Strings + +private enum Strings { + static let title = NSLocalizedString( + "parentPagePicker.title", + value: "Parent Page", + comment: "Title for the parent page picker screen" + ) +} diff --git a/WordPress/Classes/ViewRelated/Post/PostAuthorSelectorViewController.swift b/WordPress/Classes/ViewRelated/Post/PostAuthorSelectorViewController.swift deleted file mode 100644 index 882de216502d..000000000000 --- a/WordPress/Classes/ViewRelated/Post/PostAuthorSelectorViewController.swift +++ /dev/null @@ -1,78 +0,0 @@ -import UIKit -import WordPressData - -class PostAuthorSelectorViewController: SettingsSelectionViewController { - /// A completion block that is called after the user selects an option. - var completion: (() -> Void)? - - /// Representation of an Author used by the view. - private typealias Author = (displayName: String, userID: NSNumber, avatarURL: String?) - - // MARK: - Constructors - - init(post: AbstractPost) { - let authors = PostAuthorSelectorViewController.sortedActiveAuthors(for: post.blog) - - guard !authors.isEmpty, let currentAuthorID = post.authorID else { - super.init(style: .plain) - return - } - - let authorsDict: [AnyHashable: Any] = [ - "DefaultValue": currentAuthorID, - "Title": NSLocalizedString("Author", comment: "Author label."), - "Titles": authors.map { $0.displayName }, - "Values": authors.map { $0.userID }, - "CurrentValue": currentAuthorID - ] - - super.init(dictionary: authorsDict) - - onItemSelected = { [weak self] authorID in - guard - let authorID = authorID as? NSNumber, - let author = authors.first(where: { $0.userID == authorID }), - !post.isFault, post.managedObjectContext != nil - else { - return - } - - post.authorID = author.userID - post.author = author.displayName - post.authorAvatarURL = author.avatarURL - - self?.completion?() - } - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override init!(style: UITableView.Style, andDictionary dictionary: [AnyHashable: Any]!) { - super.init(style: style, andDictionary: dictionary) - } - - override init(style: UITableView.Style) { - super.init(style: style) - } - - // MARK: - Class Methods - - /// Sort authors by their display name in lexicographical order, accounting for diacritical marks. - private static func sortedActiveAuthors(for blog: Blog) -> [Author] { - /// Don't include any deleted authors. - guard let activeAuthors = blog.authors?.filter ({ !$0.deletedFromBlog }) else { - return [] - } - - return activeAuthors.compactMap { - /// Require a display name to be available. - guard let displayName = $0.displayName else { - return nil - } - - return (displayName, $0.userID, $0.avatarURL) - }.sorted(by: { $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending }) - } -} diff --git a/WordPress/Classes/ViewRelated/Post/PostEditor+JetpackSocial.swift b/WordPress/Classes/ViewRelated/Post/PostEditor+JetpackSocial.swift index 7c96775b239b..cbcf04e9a1fd 100644 --- a/WordPress/Classes/ViewRelated/Post/PostEditor+JetpackSocial.swift +++ b/WordPress/Classes/ViewRelated/Post/PostEditor+JetpackSocial.swift @@ -3,17 +3,15 @@ import WordPressData extension PostEditor { func disableSocialConnectionsIfNecessary() { + let connections = self.post.blog.sortedConnections guard RemoteFeatureFlag.jetpackSocialImprovements.enabled(), let post = self.post as? Post, let remainingShares = self.post.blog.sharingLimit?.remaining, - let connections = self.post.blog.sortedConnections as? [PublicizeConnection], remainingShares < connections.count else { return } - for connection in connections { post.disablePublicizeConnectionWithKeyringID(connection.keyringConnectionID) } } - } diff --git a/WordPress/Classes/ViewRelated/Post/PostEditor+MoreOptions.swift b/WordPress/Classes/ViewRelated/Post/PostEditor+MoreOptions.swift index b41f07f3abe9..05b296bad156 100644 --- a/WordPress/Classes/ViewRelated/Post/PostEditor+MoreOptions.swift +++ b/WordPress/Classes/ViewRelated/Post/PostEditor+MoreOptions.swift @@ -2,20 +2,30 @@ import Foundation import SVProgressHUD import WordPressFlux import WordPressUI +import SwiftUI extension PostEditor { + @MainActor func displayPostSettings() { - let viewController = PostSettingsViewController.make(for: post) - viewController.featuredImageDelegate = self as? FeaturedImageDelegate - let doneButton = UIBarButtonItem(systemItem: .done, primaryAction: .init(handler: { [weak self] _ in + // Use the new SwiftUI-based Post Settings + let originalFeaturedImageID = post.featuredImage?.mediaID + let viewModel = PostSettingsViewModel(post: post) + viewModel.onEditorPostSaved = { [weak self] in self?.editorContentWasUpdated() - self?.navigationController?.dismiss(animated: true) - })) - doneButton.accessibilityIdentifier = "close" - viewController.navigationItem.rightBarButtonItem = doneButton - let navigation = UINavigationController(rootViewController: viewController) + // Check if featured image changed and notify Gutenberg + if let self, + let gutenbergVC = self as? GutenbergViewController, + originalFeaturedImageID != self.post.featuredImage?.mediaID { + let newMediaID = self.post.featuredImage?.mediaID ?? GutenbergFeaturedImageHelper.mediaIdNoFeaturedImageSet as NSNumber + gutenbergVC.gutenbergDidRequestFeaturedImageId(newMediaID) + } + + self?.navigationController?.dismiss(animated: true) + } + let postSettingsVC = PostSettingsViewController(viewModel: viewModel) + let navigation = UINavigationController(rootViewController: postSettingsVC) self.navigationController?.present(navigation, animated: true) } diff --git a/WordPress/Classes/ViewRelated/Post/PostFormatPicker.swift b/WordPress/Classes/ViewRelated/Post/PostFormatPicker.swift new file mode 100644 index 000000000000..43b6d13b462c --- /dev/null +++ b/WordPress/Classes/ViewRelated/Post/PostFormatPicker.swift @@ -0,0 +1,149 @@ +import SwiftUI +import WordPressData +import WordPressFlux +import WordPressUI + +@MainActor +struct PostFormatPicker: View { + @ObservedObject private var post: Post + @State private var selction: String + @State private var formats: [String] + @State private var isLoading = false + @State private var error: Error? + + private let blog: Blog + private let onSubmit: (String) -> Void + + static var title: String { Strings.title } + + init(post: Post, onSubmit: @escaping (String) -> Void) { + self.post = post + self.blog = post.blog + let formats = post.blog.sortedPostFormatNames + self._formats = State(initialValue: formats) + self._selction = State(initialValue: post.postFormatText() ?? "") + self.onSubmit = onSubmit + } + + var body: some View { + Group { + if formats.isEmpty { + if isLoading { + ProgressView() + } else if let error { + EmptyStateView.failure(error: error) { + refreshPostFormats() + } + } else { + emptyStateView + } + } else { + formView + } + } + .navigationTitle(Strings.title) + .navigationBarTitleDisplayMode(.inline) + .refreshable { + await refreshPostFormats() + } + .onAppear { + if formats.isEmpty { + refreshPostFormats() + } + } + } + + private func refreshPostFormats() { + Task { + await refreshPostFormats() + } + } + + private var formView: some View { + Form { + ForEach(formats, id: \.self) { format in + Button(action: { selectFormat(format) }) { + HStack { + Text(format) + Spacer() + if selction == format { + Image(systemName: "checkmark") + .fontWeight(.medium) + .foregroundColor(Color(UIAppColor.primary)) + } + } + } + .foregroundColor(.primary) + } + } + } + + private var emptyStateView: some View { + EmptyStateView( + Strings.emptyStateTitle, + systemImage: Strings.emptyStateDescription, + description: "questionmark.folder" + ) + } + + private func selectFormat(_ format: String) { + selction = format + onSubmit(format) + } + + private func refreshPostFormats() async { + isLoading = true + error = nil + + let blogService = BlogService(coreDataStack: ContextManager.shared) + do { + try await blogService.syncPostFormats(for: post.blog) + self.formats = post.blog.sortedPostFormatNames + } catch { + self.error = error + if !formats.isEmpty { + Notice(error: error, title: Strings.errorTitle).post() + } + } + + isLoading = false + } +} + +private extension BlogService { + @MainActor func syncPostFormats(for blog: Blog) async throws { + try await withUnsafeThrowingContinuation { continuation in + syncPostFormats(for: blog, success: { + continuation.resume() + }, failure: { error in + continuation.resume(throwing: error) + }) + } + } +} + +private enum Strings { + static let title = NSLocalizedString( + "postFormatPicker.navigationTitle", + value: "Post Format", + comment: "Navigation bar title for the Post Format picker" + ) + + static let emptyStateTitle = NSLocalizedString( + "postFormatPicker.emptyState.title", + value: "No Post Formats Available", + comment: "Empty state title when no post formats are available" + ) + + static let emptyStateDescription = NSLocalizedString( + "postFormatPicker.emptyState.description", + value: "Post formats haven't been configured for this site.", + comment: "Empty state description when no post formats are available" + ) + + static let errorTitle = NSLocalizedString( + "postFormatPicker.refreshErrorMessage", + value: "Failed to refresh post formats", + comment: "Error message when post formats refresh fails" + ) +} diff --git a/WordPress/Classes/ViewRelated/Post/PostSettings/PostSettings.swift b/WordPress/Classes/ViewRelated/Post/PostSettings/PostSettings.swift new file mode 100644 index 000000000000..c4afe4b29d8b --- /dev/null +++ b/WordPress/Classes/ViewRelated/Post/PostSettings/PostSettings.swift @@ -0,0 +1,188 @@ +import Foundation +import WordPressData +import WordPressKit +import WordPressShared + +/// A plain data structure representing the subset of post/page settings that can be edited in PostSettingsView. +/// Used for change tracking and to separate UI state from Core Data objects. +struct PostSettings: Hashable { + struct Author: Hashable { + let id: Int + let displayName: String + let avatarURL: URL? + } + + var excerpt: String + var slug: String + var status: BasePost.Status + var publishDate: Date? + var password: String? + var author: Author? + var categoryIDs: Set = [] + var tags: String = "" + var featuredImageID: Int? + + // MARK: - Post-specific + var postFormat: String? + var isStickyPost = false + + // MARK: - Page-specific + var parentPageID: Int? + + // MARK: - Initialization + + /// Creates PostSettings from an AbstractPost instance. + init(from post: AbstractPost) { + excerpt = post.mt_excerpt ?? "" + slug = post.wp_slug ?? "" + status = post.status ?? .draft + publishDate = post.dateCreated + password = post.password + + if let authorID = post.authorID?.intValue, authorID > 0 { + author = Author( + id: authorID, + displayName: post.author ?? "–", + avatarURL: post.authorAvatarURL.flatMap(URL.init) + ) + } + + featuredImageID = post.featuredImage?.mediaID?.intValue + + switch post { + case let post as Post: + postFormat = post.postFormat + isStickyPost = post.isStickyPost + tags = post.tags ?? "" + categoryIDs = Set((post.categories ?? []).compactMap { + $0.categoryID?.intValue + }) + case let page as Page: + parentPageID = page.parentID?.intValue + default: + wpAssertionFailure("unsupported post type", userInfo: ["post_type": String(describing: type(of: post))]) + } + } + + // MARK: - Applying Changes + + /// Applies the settings to an AbstractPost instance. + /// Only updates properties that have actually changed. + func apply(to post: AbstractPost) { + if post.mt_excerpt != excerpt { + post.mt_excerpt = excerpt + } + if post.wp_slug != slug { + post.wp_slug = slug + } + if post.status != status { + post.status = status + } + if post.dateCreated != publishDate { + post.dateCreated = publishDate + } + if post.password != password { + post.password = password + } + if let author, post.authorID?.intValue != author.id { + post.authorID = NSNumber(value: author.id) + post.author = author.displayName + post.authorAvatarURL = author.avatarURL?.absoluteString + } + // Apply featured image changes + if let featuredImageID { + // Only update if changed + if post.featuredImage?.mediaID?.intValue != featuredImageID { + post.featuredImage = Media.existingOrStubMediaWith(mediaID: NSNumber(value: featuredImageID), inBlog: post.blog) + } + } else { + post.featuredImage = nil + } + + switch post { + case let post as Post: + // Update tags + if post.tags != tags { + post.tags = tags + } + + // Update categories + let currentCategoryIDs = Set((post.categories ?? []).compactMap { $0.categoryID?.intValue }) + if currentCategoryIDs != categoryIDs { + // Find category objects for the IDs + let allCategories = post.blog.categories ?? [] + let selectedCategories = allCategories.filter { category in + if let categoryID = category.categoryID?.intValue { + return categoryIDs.contains(categoryID) + } + return false + } + post.categories = Set(selectedCategories) + } + + // Update post format + if post.postFormat != postFormat { + post.postFormat = postFormat + } + + // Update sticky post setting + if post.isStickyPost != isStickyPost { + post.isStickyPost = isStickyPost + } + case let page as Page: + if page.parentID?.intValue != parentPageID { + page.parentID = parentPageID.map { NSNumber(value: $0) } + } + default: + wpAssertionFailure("unsupported post type", userInfo: ["post_type": String(describing: type(of: post))]) + } + } + + // MARK: - Diff Generation + + /// Creates RemotePostUpdateParameters representing the changes from the original settings. + /// Uses the existing RemotePostUpdateParameters.changes infrastructure by creating + /// a temporary post copy, applying the new settings, and computing the diff. + func makeUpdateParameters(from original: AbstractPost) -> RemotePostUpdateParameters { + guard let context = original.managedObjectContext else { + wpAssertionFailure("post must have a managed object context") + return RemotePostUpdateParameters() + } + // Create a temporary copy of the post to apply the new settings + let temporaryPost = original.createRevision() + self.apply(to: temporaryPost) + let parameters = RemotePostUpdateParameters.changes(from: original, to: temporaryPost) + context.delete(temporaryPost) + return parameters + } +} + +extension PostSettings { + var isPendingReview: Bool { + get { status == .pending } + set { status = newValue ? .pending : .draft } + } + + mutating func updateAuthor(with authorItem: PostAuthorPickerViewModel.AuthorItem) { + author = PostSettings.Author( + id: authorItem.id.intValue, + displayName: authorItem.displayName, + avatarURL: authorItem.avatarURL + ) + } + + func getCategoryNames(for post: AbstractPost) -> [String] { + guard let post = post as? Post else { + return [] + } + var categories: [Int: String] = [:] + for category in post.blog.categories ?? [] { + if let id = category.categoryID?.intValue, let name = category.categoryName { + categories[id] = name + } + } + return categoryIDs.compactMap { categories[$0] } + .sorted { $0.localizedCaseInsensitiveCompare($1) == .orderedAscending } + .map { $0.stringByDecodingXMLCharacters() } + } +} diff --git a/WordPress/Classes/ViewRelated/Post/PostSettings/PostSettingsView.swift b/WordPress/Classes/ViewRelated/Post/PostSettings/PostSettingsView.swift new file mode 100644 index 000000000000..14a7710ac2ba --- /dev/null +++ b/WordPress/Classes/ViewRelated/Post/PostSettings/PostSettingsView.swift @@ -0,0 +1,566 @@ +import UIKit +import CoreData +import Combine +import WordPressData +import WordPressKit +import WordPressShared +import WordPressUI +import SwiftUI + +final class PostSettingsViewController: UIHostingController { + private let viewModel: PostSettingsViewModel + + init(viewModel: PostSettingsViewModel) { + self.viewModel = viewModel + let postSettingsView = PostSettingsView(viewModel: viewModel) + super.init(rootView: AnyView(postSettingsView)) + } + + override func viewDidLoad() { + super.viewDidLoad() + + title = viewModel.navigationTitle + + viewModel.onDismiss = { [weak self] in + self?.presentingViewController?.dismiss(animated: true) + } + + // Set the view controller reference for navigation + // This is temporary until we can fully migrate to SwiftUI navigation + viewModel.viewController = self + } + + @preconcurrency required dynamic init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + static func showStandaloneEditor(for post: AbstractPost, from presentingVC: UIViewController) { + let viewModel = PostSettingsViewModel(post: post, isStandalone: true) + let postSettingsVC = PostSettingsViewController(viewModel: viewModel) + let navigation = UINavigationController(rootViewController: postSettingsVC) + presentingVC.present(navigation, animated: true) + } +} + +@MainActor +private struct PostSettingsView: View { + @ObservedObject var viewModel: PostSettingsViewModel + + @State private var isShowingDiscardChangesAlert = false + + var body: some View { + Form { + featuredImageSection + generalSection + if viewModel.isPost { + organizationSection + } + excerptSection + moreOptionsSection + infoSection + } + .accessibilityIdentifier("post_settings_form") + .disabled(viewModel.isSaving) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + buttonCancel + } + ToolbarItem(placement: .navigationBarTrailing) { + buttonSave + } + } + .interactiveDismissDisabled(viewModel.isSaving || viewModel.hasChanges) + .alert(viewModel.deletedAlertTitle, isPresented: $viewModel.isShowingDeletedAlert) { + Button(SharedStrings.Button.ok) { + viewModel.onDismiss?() + } + } message: { + Text(viewModel.deletedAlertMessage) + } + .confirmationDialog(Strings.discardChangesTitle, isPresented: $isShowingDiscardChangesAlert) { + Button(Strings.discardChangesButton, role: .destructive) { + viewModel.buttonCancelTapped() + } + Button(SharedStrings.Button.cancel, role: .cancel) { + // Do nothing - continue editing + } + } message: { + Text(Strings.discardChangesMessage) + } + } + + private var buttonCancel: some View { + Button(SharedStrings.Button.cancel) { + if viewModel.hasChanges { + isShowingDiscardChangesAlert = true + } else { + viewModel.buttonCancelTapped() + } + } + .tint(AppColor.tint) + .accessibilityIdentifier("post_settings_cancel_button") + } + + @ViewBuilder + private var buttonSave: some View { + if viewModel.isSaving { + ProgressView() + } else { + Group { + if viewModel.isStandalone { + Button(SharedStrings.Button.save) { + viewModel.buttonSaveTapped() + } + .buttonStyle(.borderedProminent) + .buttonBorderShape(.capsule) + } else { + Button(SharedStrings.Button.done) { + viewModel.buttonSaveTapped() + } + .fontWeight(.medium) + } + } + .accessibilityIdentifier("post_settings_save_button") + .disabled(!viewModel.hasChanges) + .tint(AppColor.tint) + } + } + + // MARK: - "Featured Image" Section + + @ViewBuilder + private var featuredImageSection: some View { + Section { + PostSettingsFeaturedImageRow(viewModel: viewModel.featuredImageViewModel) + .accessibilityIdentifier("post_settings_featured_image_cell") + } header: { + SectionHeader(Strings.featuredImageHeader) + } + } + + // MARK: - "Organization" Section + + @ViewBuilder + private var organizationSection: some View { + Section { + categoriesRow + tagsRow + } header: { + SectionHeader(Strings.taxonomyHeader) + } + } + + private var categoriesRow: some View { + Button(action: viewModel.showCategoriesPicker) { + HStack { + PostSettingsCategoriesRow(categories: viewModel.displayedCategories) + Image(systemName: "chevron.forward") + .font(.footnote.weight(.semibold)) + .foregroundColor(Color(.tertiaryLabel)) + } + } + .tint(.primary) + .accessibilityIdentifier("post_settings_categories") + } + + private var tagsRow: some View { + Button(action: viewModel.showTagsPicker) { + HStack { + PostSettingsTagsRow(tags: viewModel.displayedTags) + Image(systemName: "chevron.forward") + .font(.footnote.weight(.semibold)) + .foregroundColor(Color(.tertiaryLabel)) + } + } + .tint(.primary) + .accessibilityIdentifier("post_settings_tags") + } + + // MARK: - "Excerpt" Section + + @ViewBuilder + private var excerptSection: some View { + Section { + NavigationLink { + PostSettingsExcerptEditor(text: $viewModel.settings.excerpt) + .navigationTitle(Strings.excerptHeader) + } label: { + PostSettingExcerptRow(text: viewModel.settings.excerpt) + } + } header: { + SectionHeader(Strings.excerptHeader) + } + } + + // MARK: - "General" Section + + @ViewBuilder + private var generalSection: some View { + Section { + authorRow + if !viewModel.isDraftOrPending { + publishDateRow + visibilityRow + } + slugRow + } header: { + SectionHeader(Strings.generalHeader) + } + } + + private var authorRow: some View { + NavigationLink { + PostAuthorPicker( + blog: viewModel.post.blog, + currentAuthorID: viewModel.settings.author?.id + ) { selection in + viewModel.settings.updateAuthor(with: selection) + } + } label: { + PostSettingsAuthorRow(author: viewModel.settings.author) + } + } + + private var pendingReviewRow: some View { + Toggle(isOn: $viewModel.settings.isPendingReview) { + Text(Strings.pendingReviewLabel) + } + } + + private var publishDateRow: some View { + NavigationLink { + PublishDatePickerView(configuration: PublishDatePickerConfiguration( + date: viewModel.settings.publishDate, + isRequired: true, + timeZone: viewModel.timeZone, + updated: { date in + viewModel.settings.publishDate = date + } + )) + } label: { + SettingsRow(Strings.publishDateLabel, value: viewModel.publishDateText ?? "–") + } + } + + private var visibilityRow: some View { + NavigationLink { + PostVisibilityPicker( + selection: PostVisibilityPicker.Selection(post: viewModel.post), + dismissOnSelection: true, + onSubmit: { selection in + viewModel.updateVisibility(selection) + } + ) + } label: { + SettingsRow(Strings.visibilityLabel, value: viewModel.visibilityText) + } + } + + // MARK: - "More Options" Section + + /// The least-used options. + @ViewBuilder + private var moreOptionsSection: some View { + Section { + if viewModel.shouldShowStickyOption { + stickyPostRow + } + if viewModel.isDraftOrPending { + pendingReviewRow + } + if viewModel.isPost { + postFormatRow + } + if !viewModel.isPost { + parentPageRow + } + } header: { + SectionHeader(Strings.moreOptionsHeader) + } + } + + private var postFormatRow: some View { + NavigationLink { + PostFormatPicker(post: viewModel.post as! Post) { format in + viewModel.settings.postFormat = format + viewModel.viewController?.navigationController?.popViewController(animated: true) + } + } label: { + SettingsRow(Strings.postFormatLabel, value: viewModel.postFormatText) + } + } + + private var parentPageRow: some View { + NavigationLink { + if let page = viewModel.post as? Page { + ParentPagePicker( + blog: viewModel.post.blog, + currentPage: page, + onSelection: { selectedParentPage in + viewModel.settings.parentPageID = selectedParentPage?.postID?.intValue + viewModel.viewController?.navigationController?.popViewController(animated: true) + } + ) + } + } label: { + SettingsRow(Strings.parentPageLabel, value: viewModel.parentPageText ?? Strings.topLevelPage) + } + } + + private var slugRow: some View { + NavigationLink { + SettingsTextFieldView( + title: Strings.slugLabel, + text: $viewModel.settings.slug, + placeholder: Strings.slugPlaceholder, + hint: Strings.slugHint + ) + .autocapitalization(.none) + .autocorrectionDisabled() + } label: { + SettingsRow(Strings.slugLabel, value: viewModel.slugText) + } + } + + private var stickyPostRow: some View { + Toggle(isOn: $viewModel.settings.isStickyPost) { + Text(Strings.stickyPostLabel) + } + } + + // MARK: - "Info" Section + + @ViewBuilder + private var infoSection: some View { + if viewModel.lastEditedText != nil || viewModel.postID != nil { + Section { + if let postID = viewModel.postID { + SettingsRow(Strings.postIDLabel, value: String(postID)) + } + if let lastEditedText = viewModel.lastEditedText { + SettingsRow(Strings.lastEditedLabel, value: lastEditedText) + } + } header: { + SectionHeader(Strings.infoLabel) + } + } + } +} + +@MainActor +private struct PostSettingsAuthorRow: View { + let author: PostSettings.Author? + + var body: some View { + HStack(spacing: 6) { + Text(Strings.authorLabel) + Spacer() + if let author { + if let avatarURL = author.avatarURL { + AvatarView(style: .single(avatarURL), diameter: 22) + } + Text(author.displayName) + .foregroundColor(.secondary) + .textSelection(.enabled) + } else { + Text("—") + .foregroundColor(.secondary) + } + } + } +} + +@MainActor +private struct SettingsRow: View { + let title: String + let value: String + + init(_ title: String, value: String) { + self.title = title + self.value = value + } + + var body: some View { + HStack { + Text(title) + .layoutPriority(1) + Spacer() + Text(value) + .foregroundColor(.secondary) + .textSelection(.enabled) + } + .lineLimit(1) + } +} + +@MainActor +private struct SettingsTextFieldView: View { + let title: String + @Binding var text: String + let placeholder: String + let hint: String + + @FocusState private var isFocused: Bool + + var body: some View { + Form { + Section { + TextField(placeholder, text: $text) + .focused($isFocused) + } footer: { + Text(hint) + } + } + .navigationTitle(title) + .navigationBarTitleDisplayMode(.inline) + .onAppear { + isFocused = true + } + } +} + +private enum Strings { + static let generalHeader = NSLocalizedString( + "postSettings.section.general", + value: "General", + comment: "Section header for General settings in Post Settings" + ) + + static let authorLabel = NSLocalizedString( + "postSettings.author.label", + value: "Author", + comment: "Label for the author field in Post Settings" + ) + + static let publishDateLabel = NSLocalizedString( + "postSettings.publishDate.label", + value: "Date", + comment: "Label for the publish date field in Post Settings" + ) + + static let visibilityLabel = NSLocalizedString( + "postSettings.visibility.label", + value: "Visibility", + comment: "Label for the visibility field in Post Settings" + ) + + static let pendingReviewLabel = NSLocalizedString( + "postSettings.pendingReview.label", + value: "Pending Review", + comment: "Label for the pending review toggle in Post Settings" + ) + + static let discardChangesTitle = NSLocalizedString( + "postSettings.discardChanges.title", + value: "Discard Changes?", + comment: "Title for the discard changes confirmation dialog" + ) + + static let discardChangesMessage = NSLocalizedString( + "postSettings.discardChanges.message", + value: "You have unsaved changes. Are you sure you want to discard them?", + comment: "Message for the discard changes confirmation dialog" + ) + + static let discardChangesButton = NSLocalizedString( + "postSettings.discardChanges.button", + value: "Discard Changes", + comment: "Button to confirm discarding changes" + ) + + static let featuredImageHeader = NSLocalizedString( + "postSettings.featuredImage.header", + value: "Featured Image", + comment: "Section header for Featured Image in Post Settings" + ) + + static let taxonomyHeader = NSLocalizedString( + "postSettings.organization.header", + value: "Organization", + comment: "Label for the Organization area (categories, keywords, ...) in post settings." + ) + + static let categoriesLabel = NSLocalizedString( + "postSettings.categories.label", + value: "Categories", + comment: "Label for the categories field. Should be the same as WP core." + ) + + static let excerptHeader = NSLocalizedString( + "postSettings.excerpt.header", + value: "Excerpt", + comment: "Section header for Excerpt in Post Settings" + ) + + static let moreOptionsHeader = NSLocalizedString( + "postSettings.moreOptions.header", + value: "More Options", + comment: "Section header for More Options in Post Settings. Should use the same translation as core WP." + ) + + static let postFormatLabel = NSLocalizedString( + "postSettings.postFormat.label", + value: "Post Format", + comment: "Label for the post format field. Should be the same as WP core." + ) + + static let parentPageLabel = NSLocalizedString( + "postSettings.parentPage.label", + value: "Parent Page", + comment: "Label for the parent page field" + ) + + static let topLevelPage = NSLocalizedString( + "postSettings.parentPage.topLevel", + value: "Top level", + comment: "Cell title for the Top Level option case" + ) + + static let slugLabel = NSLocalizedString( + "postSettings.slug.label", + value: "Slug", + comment: "Label for the slug field. Should be the same as WP core." + ) + + static let slugPlaceholder = NSLocalizedString( + "postSettings.slug.placeholder", + value: "Enter slug", + comment: "Placeholder for the slug field" + ) + + static let slugHint = NSLocalizedString( + "postSettings.slug.hint", + value: "The slug is the URL-friendly version of the post title.", + comment: "Hint text for the slug field. Should be the same as the text displayed if the user clicks the (i) in Slug in Calypso." + ) + + static let stickyPostLabel = NSLocalizedString( + "postSettings.stickyPost.label", + value: "Sticky", + comment: "Label for the sticky post toggle. Sticky posts are displayed at the top of the blog." + ) + + static let infoLabel = NSLocalizedString( + "postSettings.metadata.header", + value: "Info", + comment: "Section header for Info in Post Settings" + ) + + static let permalinkLabel = NSLocalizedString( + "postSettings.permalink.label", + value: "Permalink", + comment: "Label for the permalink field in Post Settings" + ) + + static let lastEditedLabel = NSLocalizedString( + "postSettings.lastEdited.label", + value: "Last Edited", + comment: "Label for the last edited field in Post Settings" + ) + + static let postIDLabel = NSLocalizedString( + "postSettings.postID.label", + value: "ID", + comment: "Label for the post ID field in Post Settings" + ) +} diff --git a/WordPress/Classes/ViewRelated/Post/PostSettings/PostSettingsViewModel.swift b/WordPress/Classes/ViewRelated/Post/PostSettings/PostSettingsViewModel.swift new file mode 100644 index 000000000000..ae0365f5b166 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Post/PostSettings/PostSettingsViewModel.swift @@ -0,0 +1,342 @@ +import Foundation +import WordPressData +import WordPressKit +import WordPressShared +import Combine + +@MainActor +final class PostSettingsViewModel: ObservableObject { + let post: AbstractPost + let isStandalone: Bool + let featuredImageViewModel: PostSettingsFeaturedImageViewModel + + @Published var settings: PostSettings { + didSet { + refresh(from: oldValue, to: settings) + } + } + + @Published private(set) var isSaving = false + @Published private(set) var hasChanges = false + @Published private(set) var displayedCategories: [String] = [] + @Published private(set) var displayedTags: [String] = [] + @Published private(set) var parentPageText: String? + + @Published var isShowingDeletedAlert = false + + var navigationTitle: String { + isPost ? Strings.postSettingsTitle : Strings.pageSettingsTitle + } + + var deletedAlertTitle: String { + isPost ? Strings.postDeletedTitle : Strings.pageDeletedTitle + } + + var deletedAlertMessage: String { + isPost ? Strings.postDeletedMessage : Strings.pageDeletedMessage + } + + var authorDisplayName: String { + settings.author?.displayName ?? post.authorNameForDisplay() + } + + var authorAvatarURL: URL? { + settings.author?.avatarURL + } + + var publishDateText: String? { + guard let date = settings.publishDate else { + return nil + } + let formatter = DateFormatter() + formatter.dateStyle = .medium + formatter.timeStyle = .short + formatter.timeZone = timeZone + return formatter.string(from: date) + } + + var visibilityText: String { + PostVisibility(status: settings.status, password: settings.password) + .localizedTitle + } + + var slugText: String { + settings.slug.isEmpty ? (post.suggested_slug ?? "") : settings.slug + } + + var postFormatText: String { + guard let post = post as? Post else { return "" } + return post.blog.postFormatText(fromSlug: settings.postFormat) ?? NSLocalizedString("Standard", comment: "Default post format") + } + + var timeZone: TimeZone { + post.blog.timeZone ?? TimeZone.current + } + + var isDraftOrPending: Bool { + post.original().isStatus(in: [.draft, .pending]) + } + + var isPost: Bool { + post is Post + } + + var shouldShowStickyOption: Bool { + guard isPost else { return false } + // Show sticky option if blog supports WPComRESTAPI OR user is admin + return post.blog.supports(.wpComRESTAPI) || post.blog.isAdmin + } + + var lastEditedText: String? { + guard let date = post.dateModified ?? post.dateCreated else { + return nil + } + return date.toMediumString() + } + + var postID: Int? { + guard let postID = post.postID?.intValue, postID > 0 else { + return nil + } + return postID + } + + private let originalSettings: PostSettings + private var cancellables = Set() + + var onDismiss: (() -> Void)? + var onEditorPostSaved: (() -> Void)? + + /// Weak reference to the view controller for navigation. + /// This is temporary until we can fully migrate to SwiftUI navigation. + weak var viewController: UIViewController? + + init(post: AbstractPost, isStandalone: Bool = false) { + self.post = post + self.isStandalone = isStandalone + + // Initialize settings from the post + let initialSettings = PostSettings(from: post) + self.settings = initialSettings + self.originalSettings = initialSettings + + // Initialize featured image view model + self.featuredImageViewModel = PostSettingsFeaturedImageViewModel(post: post) + + // Observe selection changes from featured image view model + featuredImageViewModel.$selection.dropFirst().sink { [weak self] media in + self?.settings.featuredImageID = media?.mediaID?.intValue + }.store(in: &cancellables) + + // Initialize all cached properties + refreshDisplayedCategories() + refreshDisplayedTags() + refreshParentPageText() + + WPAnalytics.track(.postSettingsShown) + } + + private func refresh(from old: PostSettings, to new: PostSettings) { + hasChanges = new != originalSettings + + if old.categoryIDs != new.categoryIDs { + refreshDisplayedCategories() + } + if old.tags != new.tags { + refreshDisplayedTags() + } + if old.parentPageID != new.parentPageID { + refreshParentPageText() + } + } + + private func refreshDisplayedCategories() { + displayedCategories = settings.getCategoryNames(for: post) + } + + private func refreshDisplayedTags() { + displayedTags = AbstractPost.makeTags(from: settings.tags) + } + + private func refreshParentPageText() { + if let page = post as? Page, + let context = page.managedObjectContext, + let parentPageID = settings.parentPageID { + parentPageText = Page.parentPageText(in: context, parentID: NSNumber(value: parentPageID)) + } else { + parentPageText = nil + } + } + + func buttonCancelTapped() { + onDismiss?() + } + + func buttonSaveTapped() { + // Check if the post still exists + guard let context = post.managedObjectContext, + let _ = try? context.existingObject(with: post.objectID) else { + isShowingDeletedAlert = true + return + } + + guard isStandalone else { + // Apply settings and return to the editor (editor-specific) + settings.apply(to: post) + didSaveChanges() + wpAssert(onEditorPostSaved != nil, "configuration missing") + onEditorPostSaved?() + return + } + + isSaving = true + Task { + await actuallySave() + } + } + + private func actuallySave() async { + do { + let coordinator = PostCoordinator.shared + if coordinator.isSyncAllowed(for: post) { + let revision = post.createRevision() + settings.apply(to: revision) + coordinator.setNeedsSync(for: revision) + } else { + // When sync is not allowed, use the changes parameter + let changes = settings.makeUpdateParameters(from: post) + try await coordinator.save(post, changes: changes) + } + didSaveChanges() + onDismiss?() + } catch { + isSaving = false + // `PostCoordinator` handles errors by showing an alert when needed + } + } + + private func didSaveChanges() { + trackChanges(from: originalSettings, to: settings) + } + + func updateVisibility(_ selection: PostVisibilityPicker.Selection) { + track(.editorPostVisibilityChanged) + + switch selection.type { + case .public, .protected: + if post.original().status == .scheduled { + // Keep it scheduled + } else { + settings.status = .publish + } + case .private: + settings.status = .publishPrivate + } + settings.password = selection.password.isEmpty ? nil : selection.password + } + + // MARK: - Navigation + + func showCategoriesPicker() { + let categoriesVC = PostSettingsCategoriesPickerViewController( + blog: post.blog, + selectedCategoryIDs: settings.categoryIDs + ) { [weak self] newSelectedIDs in + self?.settings.categoryIDs = newSelectedIDs + } + viewController?.navigationController?.pushViewController(categoriesVC, animated: true) + } + + func showTagsPicker() { + let tagsVC = PostTagPickerViewController( + tags: settings.tags, + blog: post.blog + ) + tagsVC.onValueChanged = { [weak self] newTagsString in + self?.settings.tags = newTagsString + } + viewController?.navigationController?.pushViewController(tagsVC, animated: true) + } + + // MARK: - Analytics + + private func trackChanges(from old: PostSettings, to new: PostSettings) { + if old.author?.id != new.author?.id { + track(.editorPostAuthorChanged) + } + if old.publishDate != new.publishDate { + track(.editorPostScheduledChanged) + } + if old.tags != new.tags { + track(.editorPostTagsChanged) + } + if old.postFormat != new.postFormat { + track(.editorPostFormatChanged) + } + if old.categoryIDs != new.categoryIDs { + track(.editorPostCategoryChanged) + } + if old.featuredImageID != new.featuredImageID { + let action = new.featuredImageID == nil ? "removed" : "changed" + WPAnalytics.track(.editorPostFeaturedImageChanged, properties: ["via": "settings", "action": action]) + } + if old.excerpt != new.excerpt { + track(.editorPostExcerptChanged) + } + if old.slug != new.slug { + track(.editorPostSlugChanged) + } + if old.status != new.status { + if (old.status == .pending) != (new.status == .pending) { + track(.editorPostPendingReviewChanged) + } + } + if old.isStickyPost != new.isStickyPost { + track(.editorPostStickyChanged) + } + } + + private func track(_ event: WPAnalyticsEvent) { + WPAnalytics.track(event, properties: ["via": "settings"]) + } +} + +// MARK: - Localized Strings + +private enum Strings { + static let postSettingsTitle = NSLocalizedString( + "postSettings.navigationTitle.post", + value: "Post Settings", + comment: "The title of the Post Settings screen." + ) + + static let pageSettingsTitle = NSLocalizedString( + "postSettings.navigationTitle.page", + value: "Page Settings", + comment: "The title of the Page Settings screen." + ) + + static let postDeletedTitle = NSLocalizedString( + "postSettings.postDeleted.title", + value: "Post Deleted", + comment: "Title of alert when trying to save a deleted post" + ) + + static let pageDeletedTitle = NSLocalizedString( + "postSettings.pageDeleted.title", + value: "Page Deleted", + comment: "Title of alert when trying to save a deleted page" + ) + + static let postDeletedMessage = NSLocalizedString( + "postSettings.postDeleted.message", + value: "This post has been deleted and can no longer be saved.", + comment: "Message when trying to save a deleted post" + ) + + static let pageDeletedMessage = NSLocalizedString( + "postSettings.pageDeleted.message", + value: "This page has been deleted and can no longer be saved.", + comment: "Message when trying to save a deleted page" + ) +} diff --git a/WordPress/Classes/ViewRelated/Post/PostSettings/Views/PostSettingExcerptRow.swift b/WordPress/Classes/ViewRelated/Post/PostSettings/Views/PostSettingExcerptRow.swift new file mode 100644 index 000000000000..8e6c6ed6fb83 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Post/PostSettings/Views/PostSettingExcerptRow.swift @@ -0,0 +1,34 @@ +import SwiftUI + +struct PostSettingExcerptRow: View { + var text: String + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + Text(text.isEmpty ? Strings.excerptPlaceholder : text) + .lineLimit(3) + .foregroundColor(text.isEmpty ? Color(.tertiaryLabel) : .primary) + .font(.body) + .multilineTextAlignment(.leading) + + if !text.isEmpty { + Text("\(text.count) characters") + .font(.caption) + .foregroundColor(.secondary) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + static var localizedPlaceholderText: String { + Strings.excerptPlaceholder + } +} + +private enum Strings { + static let excerptPlaceholder = NSLocalizedString( + "postSettings.excerpt.placeholder", + value: "Write a brief summary of your post to appear on blog index, archives, and search results.", + comment: "Placeholder text for the excerpt field in Post Settings" + ) +} diff --git a/WordPress/Classes/ViewRelated/Post/PostSettings/Views/PostSettingsCategoriesPickerViewController.swift b/WordPress/Classes/ViewRelated/Post/PostSettings/Views/PostSettingsCategoriesPickerViewController.swift new file mode 100644 index 000000000000..159531d1ab6e --- /dev/null +++ b/WordPress/Classes/ViewRelated/Post/PostSettings/Views/PostSettingsCategoriesPickerViewController.swift @@ -0,0 +1,40 @@ +import UIKit +import WordPressData + +/// A subclass of PostCategoriesViewController for use in PostSettings. +/// This is a temporary solution until PostCategoriesViewController can be replaced with SwiftUI. +final class PostSettingsCategoriesPickerViewController: PostCategoriesViewController { + private let _onCategoriesChanged: (Set) -> Void + + init(blog: Blog, selectedCategoryIDs: Set, onCategoriesChanged: @escaping (Set) -> Void) { + self._onCategoriesChanged = onCategoriesChanged + + // Get currently selected categories + let selectedCategories = blog.categories?.filter { category in + if let categoryID = category.categoryID?.intValue { + return selectedCategoryIDs.contains(categoryID) + } + return false + } ?? [] + + super.init(blog: blog, currentSelection: Array(selectedCategories), selectionMode: .post) + + self.delegate = self + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +// MARK: - PostCategoriesViewControllerDelegate + +extension PostSettingsCategoriesPickerViewController: PostCategoriesViewControllerDelegate { + func postCategoriesViewController(_ controller: PostCategoriesViewController, didUpdateSelectedCategories categories: NSSet) { + // Convert NSSet of PostCategory objects to Set of category IDs + let newSelectedIDs = Set(categories.compactMap { category in + (category as? PostCategory)?.categoryID?.intValue + }) + _onCategoriesChanged(newSelectedIDs) + } +} diff --git a/WordPress/Classes/ViewRelated/Post/PostSettings/Views/PostSettingsCategoriesRow.swift b/WordPress/Classes/ViewRelated/Post/PostSettings/Views/PostSettingsCategoriesRow.swift new file mode 100644 index 000000000000..13e4bf335ef9 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Post/PostSettings/Views/PostSettingsCategoriesRow.swift @@ -0,0 +1,45 @@ +import SwiftUI +import WordPressUI +import WordPressData + +struct PostSettingsCategoriesRow: View { + let categories: [String] + + var body: some View { + HStack { + PostSettingsIconView("wpdl-category") + .padding(.trailing, 2) + + VStack(alignment: .leading, spacing: 2) { + Text(Strings.categoriesLabel) + .font(.body) + .foregroundColor(.primary) + + if categories.isEmpty { + Text(Strings.addCategory) + .font(.body) + .font(.subheadline) + .foregroundColor(Color(.tertiaryLabel)) + } else { + PostSettingsTruncatedArrayTextView(values: categories) + } + } + + Spacer() + } + } +} + +private enum Strings { + static let categoriesLabel = NSLocalizedString( + "postSettings.categories.label", + value: "Categories", + comment: "Label for the category field. Should be the same as WP core." + ) + + static let addCategory = NSLocalizedString( + "postSettings.categories.addCategoryButton", + value: "Add Category", + comment: "Label for the add category button field. Should be the same as WP core." + ) +} diff --git a/WordPress/Classes/ViewRelated/Post/PostSettings/Views/PostSettingsExcerptEditor.swift b/WordPress/Classes/ViewRelated/Post/PostSettings/Views/PostSettingsExcerptEditor.swift new file mode 100644 index 000000000000..c5e5d3f52819 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Post/PostSettings/Views/PostSettingsExcerptEditor.swift @@ -0,0 +1,94 @@ +import SwiftUI +import WordPressUI + +struct PostSettingsExcerptEditor: View { + @Binding var text: String + + @State private var wordCount = 0 + + @FocusState private var isFocused: Bool + + @Environment(\.dismiss) private var dismiss + + private let placeholder = PostSettingExcerptRow.localizedPlaceholderText + + var body: some View { + Form { + Section { + TextEditor(text: $text) + .focused($isFocused) + .frame(minHeight: 200) + .overlay(alignment: .topLeading) { + if text.isEmpty { + Text(placeholder) + .foregroundColor(Color(.tertiaryLabel)) + .padding(.horizontal, 4) + .padding(.vertical, 8) + .allowsHitTesting(false) + } + } + .listRowInsets(EdgeInsets(top: 8, leading: 12, bottom: 0, trailing: 12)) + } footer: { + HStack { + Text(String.localizedStringWithFormat(Strings.characterCount, text.count)) + .foregroundColor(.secondary) + Spacer() + Text(String.localizedStringWithFormat(Strings.wordCount, wordCount)) + .foregroundColor(.secondary) + } + .font(.caption) + } + } + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button(SharedStrings.Button.done) { + dismiss() + } + .fontWeight(.medium) + } + } + .onAppear { + // Delay to ensure smooth transition + DispatchQueue.main.asyncAfter(deadline: .now() + 0.33) { + isFocused = true + } + // Initial word count + wordCount = text.wordCount + } + .onChange(of: text) { newValue in + // Debounce word count calculation + Task { + try await Task.sleep(for: .milliseconds(330)) + let wordCount = newValue.wordCount + await MainActor.run { + self.wordCount = wordCount + } + } + } + } +} + +private extension String { + var wordCount: Int { + var count = 0 + enumerateSubstrings(in: startIndex.. Void + @ScaledMetric(relativeTo: .body) var height = 120 var body: some View { - if let image = post.featuredImage { - SiteMediaImage(media: image, size: .large) - .loadingStyle(.spinner) - .accessibilityIdentifier("featured_image_current_image") - .aspectRatio(1.0 / ReaderPostCell.coverAspectRatio, contentMode: .fit) - .overlay { - menu - } - .contextMenu { - actions - } - } else { - if viewModel.upload != nil { - // The upload state when no image is selected. For the "Replace" - // flow, the app shows the upload differently (see `menu`). - uploading + Group { + if let image = viewModel.selection { + makeMediaView(with: image) } else { - makeMediaPicker { - Label(Strings.buttonSetFeaturedImage, systemImage: "photo.badge.plus") - .frame(maxWidth: .infinity) - .contentShape(Rectangle()) // Make the whole cell tappable + Group { + if viewModel.upload != nil { + // The upload state when no image is selected. For the "Replace" + // flow, the app shows the upload differently (see `menu`). + uploadingStateView + } else { + makeMediaPicker { + setFeaturedImageView + } + } } + .listRowBackground(Color.clear) + .frame(height: height) + } + } + .listRowInsets(EdgeInsets.zero) + } + + private func makeMediaView(with image: Media) -> some View { + SiteMediaImage(media: image, size: .large) + .loadingStyle(.spinner) + // warning: SiteMediaImage doesn't seem to reload otherwise; might want to change it later + .id(image) + .aspectRatio(1.0 / ReaderPostCell.coverAspectRatio, contentMode: .fit) + .overlay { + menu + } + .contextMenu { + actions } + .sheet(item: $presentedMedia) { media in + LightboxView(media: media) + .ignoresSafeArea() + } + } + + private var setFeaturedImageView: some View { + makeWithProminentBackground { + VStack(spacing: 4) { + Image(systemName: "photo.on.rectangle.angled") + .font(.title) + .symbolRenderingMode(.hierarchical) + + Text(Strings.buttonSetFeaturedImage) + .font(.body) + } + .foregroundColor(.accentColor) + .fontWeight(.medium) } } @@ -51,6 +81,7 @@ struct PostSettingsFeaturedImageCell: View { Image(systemName: "ellipsis") .foregroundStyle(Color(.label)) .font(.system(size: 18)) + .accessibilityIdentifier("featured_image_current_image_menu") // not ideal } } .shadow(color: .black.opacity(0.5), radius: 10) @@ -62,10 +93,12 @@ struct PostSettingsFeaturedImageCell: View { @ViewBuilder private var actions: some View { if viewModel.upload == nil { - Button(SharedStrings.Button.view, systemImage: "plus.magnifyingglass", action: onViewTapped) - .accessibilityIdentifier("featured_image_button_view") + Button(SharedStrings.Button.view, systemImage: "plus.magnifyingglass") { + presentedMedia = viewModel.selection + } + .accessibilityIdentifier("featured_image_button_view") makeMediaPicker { - Button(Strings.replaceImage, systemImage: "photo.badge.plus", action: onViewTapped) + Button(Strings.replaceImage, systemImage: "photo.badge.plus", action: {}) .accessibilityIdentifier("featured_image_button_replace") } Button(SharedStrings.Button.remove, systemImage: "trash", role: .destructive, action: viewModel.buttonRemoveTapped) @@ -77,32 +110,53 @@ struct PostSettingsFeaturedImageCell: View { } } - private var uploading: some View { - HStack(alignment: .center, spacing: 0) { - ProgressView() - .padding(.trailing, 12) - - Text(Strings.uploading) - .foregroundStyle(.secondary) - .lineLimit(1) - - Spacer(minLength: 8) + private var uploadingStateView: some View { + Menu { + Button(role: .destructive, action: viewModel.buttonCancelTapped) { + Label(Strings.cancelUpload, systemImage: "xmark.circle.fill") + } + } label: { + makeWithProminentBackground { + HStack { + ProgressView() - Menu { - Button(role: .destructive, action: viewModel.buttonCancelTapped) { - Label(Strings.cancelUpload, systemImage: "trash") + Text(Strings.uploading) + .font(.headline) + .fontWeight(.medium) } - } label: { - Image(systemName: "ellipsis") - .font(.subheadline) - .tint(.secondary) + .tint(.accentColor) + .foregroundColor(.accentColor) + } + .overlay(alignment: .topTrailing) { + Image(systemName: "ellipsis.circle") + .foregroundStyle(Color.secondary) + .padding(12) } } } + /// A nice tinted background for the button and other states. + private func makeWithProminentBackground(@ViewBuilder content: @escaping () -> Content) -> some View { + ZStack { + // System background that adapts to dark mode + RoundedRectangle(cornerRadius: 12) + .fill(Color(UIColor.secondarySystemGroupedBackground)) + + // Very subtle accent tint + RoundedRectangle(cornerRadius: 12) + .fill(Color.accentColor.opacity(0.02)) + + content() + + // Prominent border + RoundedRectangle(cornerRadius: 12) + .strokeBorder(Color.accentColor.opacity(0.3), lineWidth: 1) + } + } + private func makeMediaPicker(@ViewBuilder content: @escaping () -> Content) -> some View { let configuration = MediaPickerConfiguration( - sources: [.photos, .camera, .playground, .siteMedia(blog: post.blog)], + sources: [.photos, .camera, .playground, .siteMedia(blog: viewModel.post.blog)], filter: .images ) return MediaPicker(configuration: configuration, onSelection: viewModel.setFeaturedImage) { @@ -111,19 +165,18 @@ struct PostSettingsFeaturedImageCell: View { } } -public final class PostSettingsFeaturedImageViewModel: NSObject, ObservableObject { +public final class PostSettingsFeaturedImageViewModel: ObservableObject { @Published private(set) var upload: Media? + @Published var selection: Media? let post: AbstractPost private var receipt: UUID? private let coordinator = MediaCoordinator.shared - @objc public weak var tableView: UITableView? - @objc public weak var delegate: FeaturedImageDelegate? - - @objc public init(post: AbstractPost) { + public init(post: AbstractPost) { self.post = post + self.selection = post.featuredImage } func setFeaturedImage(selection: MediaPickerSelection) { @@ -177,11 +230,9 @@ public final class PostSettingsFeaturedImageViewModel: NSObject, ObservableObjec } private func setFeaturedImage(_ media: Media?) { - upload = nil - post.featuredImage = media - delegate?.gutenbergDidRequestFeaturedImageId(media?.mediaID ?? GutenbergFeaturedImageHelper.mediaIdNoFeaturedImageSet as NSNumber) - UIView.performWithoutAnimation { - tableView?.reloadData() + withAnimation { + upload = nil + selection = media } } } diff --git a/WordPress/Classes/ViewRelated/Post/PostSettings/Views/PostSettingsIconView.swift b/WordPress/Classes/ViewRelated/Post/PostSettings/Views/PostSettingsIconView.swift new file mode 100644 index 000000000000..00cf07c8cf71 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Post/PostSettings/Views/PostSettingsIconView.swift @@ -0,0 +1,23 @@ +import SwiftUI +import WordPressUI + +struct PostSettingsIconView: View { + let imageName: String + + @ScaledMetric(relativeTo: .body) var width = 17 + + init(_ imageName: String) { + self.imageName = imageName + } + + var body: some View { + ZStack { + RoundedRectangle(cornerRadius: 8) + .fill(Color.secondary.opacity(0.15)) + .frame(width: 30, height: 30) + + ScaledImage(imageName, height: 19) + .foregroundColor(.secondary) + } + } +} diff --git a/WordPress/Classes/ViewRelated/Post/PostSettings/Views/PostSettingsTagsRow.swift b/WordPress/Classes/ViewRelated/Post/PostSettings/Views/PostSettingsTagsRow.swift new file mode 100644 index 000000000000..ac7b6315580c --- /dev/null +++ b/WordPress/Classes/ViewRelated/Post/PostSettings/Views/PostSettingsTagsRow.swift @@ -0,0 +1,45 @@ +import SwiftUI +import WordPressUI +import WordPressData + +struct PostSettingsTagsRow: View { + let tags: [String] + + var body: some View { + HStack { + PostSettingsIconView("wpdl-tag") + .padding(.trailing, 2) + + VStack(alignment: .leading, spacing: 2) { + Text(Strings.tagsLabel) + .font(.body) + .foregroundColor(.primary) + + if tags.isEmpty { + Text(Strings.addTags) + .font(.body) + .font(.subheadline) + .foregroundColor(Color(.tertiaryLabel)) + } else { + PostSettingsTruncatedArrayTextView(values: tags) + } + } + + Spacer() + } + } +} + +private enum Strings { + static let tagsLabel = NSLocalizedString( + "postSettings.tags.label", + value: "Tags", + comment: "Label for the tags field. Should be the same as WP core." + ) + + static let addTags = NSLocalizedString( + "postSettings.tags.addTagsButton", + value: "Add Tags", + comment: "Label for the add tags button field. Should be the same as WP core." + ) +} diff --git a/WordPress/Classes/ViewRelated/Post/PostSettings/Views/PostSettingsTruncatedArrayTextView.swift b/WordPress/Classes/ViewRelated/Post/PostSettings/Views/PostSettingsTruncatedArrayTextView.swift new file mode 100644 index 000000000000..2fc7cbdecb37 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Post/PostSettings/Views/PostSettingsTruncatedArrayTextView.swift @@ -0,0 +1,42 @@ +import SwiftUI + +struct PostSettingsTruncatedArrayTextView: View { + let values: [String] + + var body: some View { + /// Show the longest version that fits up to four. + /// Example: "Techology, Blogging (+3)" + ViewThatFits(in: .horizontal) { + if values.count >= 4 { + ItemView(values: Array(values.prefix(4)), remainingCount: values.count - 4) + } + if values.count >= 3 { + ItemView(values: Array(values.prefix(3)), remainingCount: values.count - 3) + } + if values.count >= 2 { + ItemView(values: Array(values.prefix(2)), remainingCount: values.count - 2) + } + ItemView(values: Array(values.prefix(1)), remainingCount: values.count - 1) + } + } +} + +private struct ItemView: View { + let values: [String] + let remainingCount: Int + + var body: some View { + HStack(alignment: .lastTextBaseline, spacing: 4) { + Text(values.joined(separator: ", ")) + .font(.subheadline) + .foregroundColor(.secondary) + if remainingCount > 0 { + Text("(+\(remainingCount))") + .font(.system(.subheadline, design: .monospaced)) + .foregroundColor(.secondary) + .tracking(-0.5) + } + } + .lineLimit(1) + } +} diff --git a/WordPress/Classes/ViewRelated/Post/PostSettingsViewController+JetpackSocial.swift b/WordPress/Classes/ViewRelated/Post/PostSettingsViewController+JetpackSocial.swift deleted file mode 100644 index ca158908c2a7..000000000000 --- a/WordPress/Classes/ViewRelated/Post/PostSettingsViewController+JetpackSocial.swift +++ /dev/null @@ -1,186 +0,0 @@ -import SwiftUI -import AutomatticTracks -import WordPressData -import WordPressShared - -extension PostSettingsViewController { - - // MARK: - No connection view - - @objc public func showNoConnection() -> Bool { - let isJetpackSocialEnabled = RemoteFeatureFlag.jetpackSocialImprovements.enabled() - let isNoConnectionViewHidden = UserPersistentStoreFactory.instance().bool(forKey: hideNoConnectionViewKey()) - let blogSupportsPublicize = apost.blog.supportsPublicize() - let blogHasNoConnections = publicizeConnections.count == 0 && unsupportedConnections.count == 0 - let blogHasServices = availableServices().count > 0 - - return isJetpackSocialEnabled - && !isNoConnectionViewHidden - && blogSupportsPublicize - && blogHasNoConnections - && blogHasServices - && !isPostPrivate - } - - @objc public func createNoConnectionView() -> UIView { - WPAnalytics.track(.jetpackSocialNoConnectionCardDisplayed, - properties: ["source": Constants.trackingSource]) - let services = availableServices() - let viewModel = JetpackSocialNoConnectionViewModel(services: services, - onConnectTap: onConnectTap(), - onNotNowTap: onNotNowTap()) - let viewController = JetpackSocialNoConnectionView.createHostController(with: viewModel) - - // Returning just the view means the view controller will deallocate but we don't need a - // reference to it. The view itself holds onto the view model. - return viewController.view - } - - // MARK: - Remaining shares view - - @objc public func showRemainingShares() -> Bool { - let isJetpackSocialEnabled = RemoteFeatureFlag.jetpackSocialImprovements.enabled() - let blogSupportsPublicize = apost.blog.supportsPublicize() - let blogHasConnections = publicizeConnections.count > 0 - let blogHasSharingLimit = apost.blog.sharingLimit != nil - - return isJetpackSocialEnabled - && blogSupportsPublicize - && blogHasConnections - && blogHasSharingLimit - && !isPostPrivate - } - - @objc public func createRemainingSharesView() -> UIView { - guard let sharingLimit = apost.blog.sharingLimit else { - // This scenario *shouldn't* happen since we check that the publicize info is not nil before - // showing this view - assertionFailure("No sharing limit on the blog") - let error = JetpackSocialError.missingSharingLimit - CrashLogging.main.logError(error, userInfo: ["source": "post_settings"]) - return UIView() - } - WPAnalytics.track(.jetpackSocialShareLimitDisplayed, - properties: ["source": Constants.trackingSource]) - - let shouldDisplayWarning = publicizeConnections.count > sharingLimit.remaining - let viewModel = JetpackSocialRemainingSharesViewModel(remaining: sharingLimit.remaining, - displayWarning: shouldDisplayWarning, - onSubscribeTap: onSubscribeTap()) - let hostController = UIHostingController(rootView: JetpackSocialSettingsRemainingSharesView(viewModel: viewModel)) - hostController.view.translatesAutoresizingMaskIntoConstraints = false - hostController.view.backgroundColor = .secondarySystemGroupedBackground - return hostController.view - } - - // MARK: - Social share cells - - @objc public func userCanEditSharing() -> Bool { - guard let post = self.apost as? Post else { - return false - } - guard RemoteFeatureFlag.jetpackSocialImprovements.enabled() else { - return post.canEditPublicizeSettings() - } - - return post.canEditPublicizeSettings() && remainingSocialShares() > 0 - } - - @objc public func remainingSocialShares() -> Int { - self.apost.blog.sharingLimit?.remaining ?? .max - } - -} - -// MARK: - Private methods - -private extension PostSettingsViewController { - - var isPostPrivate: Bool { - apost.status == .publishPrivate - } - - func hideNoConnectionViewKey() -> String { - guard let dotComID = apost.blog.dotComID?.stringValue else { - return Constants.hideNoConnectionViewKey - } - - return "\(dotComID)-\(Constants.hideNoConnectionViewKey)" - } - - func onConnectTap() -> () -> Void { - return { [weak self] in - WPAnalytics.track(.jetpackSocialNoConnectionCTATapped, - properties: ["source": Constants.trackingSource]) - guard let blog = self?.apost.blog, - let controller = SharingViewController(blog: blog, delegate: nil) else { - return - } - self?.navigationController?.pushViewController(controller, animated: true) - } - } - - func onNotNowTap() -> () -> Void { - return { [weak self] in - WPAnalytics.track(.jetpackSocialNoConnectionCardDismissed, - properties: ["source": Constants.trackingSource]) - guard let key = self?.hideNoConnectionViewKey() else { - return - } - UserPersistentStoreFactory.instance().set(true, forKey: key) - self?.tableView.reloadData() - } - } - - func onSubscribeTap() -> () -> Void { - return { [weak self] in - WPAnalytics.track(.jetpackSocialUpgradeLinkTapped, - properties: ["source": Constants.trackingSource]) - guard let blog = self?.apost.blog, - let hostname = blog.hostname, - let url = URL(string: "https://wordpress.com/checkout/\(hostname)/jetpack_social_basic_yearly") else { - return - } - let webViewController = WebViewControllerFactory.controller(url: url, - blog: blog, - source: "post_settings_remaining_shares_subscribe_now") { - self?.checkoutDismissed() - } - let navigationController = UINavigationController(rootViewController: webViewController) - self?.present(navigationController, animated: true) - } - } - - func checkoutDismissed() { - let coreDataStack = ContextManager.shared - let service = BlogService(coreDataStack: coreDataStack) - service.syncBlog(apost.blog) { [weak self] in - let sharingLimit: PublicizeInfo.SharingLimit? = coreDataStack.performQuery { context in - guard let dotComID = self?.apost.blog.dotComID, - let blog = Blog.lookup(withID: dotComID, in: context) else { - return nil - } - return blog.sharingLimit - } - if sharingLimit == nil { - self?.reloadData() - } - } failure: { error in - DDLogError("Failed to sync blog after dismissing checkout webview due to error: \(error)") - } - } - - func availableServices() -> [PublicizeService] { - let context = apost.managedObjectContext ?? ContextManager.shared.mainContext - let services = try? PublicizeService.allSupportedServices(in: context) - return services ?? [] - } - - // MARK: - Constants - - struct Constants { - static let hideNoConnectionViewKey = "post-settings-social-no-connection-view-hidden" - static let trackingSource = "post_settings" - } - -} diff --git a/WordPress/Classes/ViewRelated/Post/PostSettingsViewController+Swift.swift b/WordPress/Classes/ViewRelated/Post/PostSettingsViewController+Swift.swift deleted file mode 100644 index 762707c6b9f8..000000000000 --- a/WordPress/Classes/ViewRelated/Post/PostSettingsViewController+Swift.swift +++ /dev/null @@ -1,307 +0,0 @@ -import UIKit -import CoreData -import Combine -import WordPressData -import WordPressKit -import WordPressShared -import SwiftUI - -extension PostSettingsViewController { - static func make(for post: AbstractPost) -> PostSettingsViewController { - switch post { - case let post as Post: - return PostSettingsViewController(post: post) - case let page as Page: - return PageSettingsViewController(post: page) - default: - fatalError("Unsupported entity: \(post)") - } - } - - static func showStandaloneEditor(for post: AbstractPost, from presentingViewController: UIViewController) { - let revision = post.createRevision() - let viewController = PostSettingsViewController.make(for: revision) - viewController.isStandalone = true - let navigation = UINavigationController(rootViewController: viewController) - presentingViewController.present(navigation, animated: true) - } - - @objc public var isDraftOrPending: Bool { - apost.original().isStatus(in: [.draft, .pending]) - } - - @objc public func onViewDidLoad() { - if isStandalone { - setupStandaloneEditor() - } - if let postID = apost.postID, postID.intValue > 0 { - tableView.tableFooterView = EntityMetadataTableFooterView.make(id: postID) - } - } - - private func setupStandaloneEditor() { - wpAssert(navigationController?.presentationController != nil) - navigationController?.presentationController?.delegate = self - - refreshNavigationBarButtons() - navigationItem.rightBarButtonItem?.isEnabled = false - - var cancellables: [AnyCancellable] = [] - - let originalPostID = (apost.original ?? apost).objectID - - NotificationCenter.default - .publisher(for: NSManagedObjectContext.didChangeObjectsNotification, object: apost.managedObjectContext) - .sink { [weak self] notification in - self?.didChangeObjects(notification, originalPostID: originalPostID) - }.store(in: &cancellables) - - NotificationCenter.default - .publisher(for: UIApplication.willTerminateNotification) - .sink { [weak self] _ in - self?.deleteRevision() - }.store(in: &cancellables) - - apost.objectWillChange.sink { [weak self] in - self?.didUpdateSettings() - }.store(in: &cancellables) - - objc_setAssociatedObject(self, &PostSettingsViewController.cancellablesKey, cancellables, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) - } - - private func didUpdateSettings() { - navigationItem.rightBarButtonItem?.isEnabled = !changes.isEmpty - } - - private func refreshNavigationBarButtons() { - navigationItem.leftBarButtonItem = UIBarButtonItem(barButtonSystemItem: .cancel, target: self, action: #selector(buttonCancelTapped)) - - let buttonSave = UIBarButtonItem(barButtonSystemItem: isStandalone ? .save : .done, target: self, action: #selector(buttonSaveTapped)) - buttonSave.accessibilityLabel = "save" - navigationItem.rightBarButtonItem = buttonSave - } - - @objc private func buttonCancelTapped() { - wpAssert(self.isStandalone, "should only be shown for a standalone editor") - deleteRevision() - presentingViewController?.dismiss(animated: true) - } - - @objc private func buttonSaveTapped() { - navigationItem.rightBarButtonItem = .activityIndicator - setEnabled(false) - - Task { @MainActor in - do { - let coordinator = PostCoordinator.shared - if coordinator.isSyncAllowed(for: apost) { - coordinator.setNeedsSync(for: apost) - } else { - try await coordinator.save(apost) - } - presentingViewController?.dismiss(animated: true) - } catch { - setEnabled(true) - refreshNavigationBarButtons() - } - } - } - - private func didChangeObjects(_ notification: Foundation.Notification, originalPostID: NSManagedObjectID) { - guard let userInfo = notification.userInfo else { return } - - let deletedObjects = ((userInfo[NSDeletedObjectsKey] as? Set) ?? []) - if deletedObjects.contains(where: { $0.objectID == originalPostID }) { - presentingViewController?.dismiss(animated: true) - } - } - - private var changes: RemotePostUpdateParameters { - guard let original = apost.original else { - return RemotePostUpdateParameters() - } - return RemotePostUpdateParameters.changes(from: original, to: apost) - } - - private func deleteRevision() { - apost.original?.deleteRevision() - apost.managedObjectContext.map(ContextManager.shared.saveContextAndWait) - } - - private func setEnabled(_ isEnabled: Bool) { - navigationItem.leftBarButtonItem?.isEnabled = isEnabled - isModalInPresentation = !isEnabled - tableView.tintAdjustmentMode = isEnabled ? .automatic : .dimmed - tableView.isUserInteractionEnabled = isEnabled - } - - private static var cancellablesKey: UInt8 = 0 -} - -extension PostSettingsViewController: UIAdaptivePresentationControllerDelegate { - public func presentationControllerDidDismiss(_ presentationController: UIPresentationController) { - deleteRevision() - } -} - -// MARK: - PostSettingsViewController (Visibility) - -extension PostSettingsViewController { - @objc public func showPostVisibilitySelector() { - let view = PostVisibilityPicker(selection: .init(post: apost)) { [weak self] selection in - guard let self else { return } - - WPAnalytics.track(.editorPostVisibilityChanged, properties: ["via": "settings"]) - - switch selection.type { - case .public, .protected: - if self.apost.original().status == .scheduled { - // Keep it scheduled - } else { - self.apost.status = .publish - } - case .private: - if self.apost.original().status == .scheduled { - DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(300)) { - self.showWarningPostWillBePublishedAlert() - } - } - self.apost.status = .publishPrivate - } - self.apost.password = selection.password.isEmpty ? nil : selection.password - self.navigationController?.popViewController(animated: true) - self.reloadData() - } - let viewController = UIHostingController(rootView: view) - viewController.title = PostVisibilityPicker.title - navigationController?.pushViewController(viewController, animated: true) - } - - private func showWarningPostWillBePublishedAlert() { - let alert = UIAlertController(title: nil, message: Strings.warningPostWillBePublishedAlertMessage, preferredStyle: .alert) - alert.addAction(UIAlertAction(title: SharedStrings.Button.ok, style: .default)) - present(alert, animated: true) - } -} - -// MARK: - PostSettingsViewController (Publish Date) - -extension PostSettingsViewController { - @objc public func showPublishDatePicker() { - var viewModel = PublishSettingsViewModel(post: self.apost) - let viewController = PublishDatePickerViewController.make(viewModel: viewModel) { date in - WPAnalytics.track(.editorPostScheduledChanged, properties: ["via": "settings"]) - viewModel.setDate(date) - } - self.navigationController?.pushViewController(viewController, animated: true) - } -} - -// MARK: - PostSettingsViewController (Page Attributes) - -extension PostSettingsViewController { - @objc public func showParentPageController() { - guard let page = (self.apost as? Page) else { - wpAssertionFailure("post has to be a page") - return - } - Task { - await showParentPageController(for: page) - } - } - - @MainActor - private func showParentPageController(for page: Page) async { - let request = NSFetchRequest(entityName: Page.entityName()) - let filter = PostListFilter.publishedFilter() - request.predicate = filter.predicate(for: apost.blog, author: .everyone) - request.sortDescriptors = filter.sortDescriptors - do { - let context = ContextManager.shared.mainContext - var pages = try await PostRepository().buildPageTree(request: request) - .map { pageID, hierarchyIndex in - let page = try context.existingObject(with: pageID) - page.hierarchyIndex = hierarchyIndex - return page - } - if let index = pages.firstIndex(of: page) { - pages = pages.remove(from: index) - } - let viewController = ParentPageSettingsViewController.make(with: pages, selectedPage: page) - viewController.isModalInPresentation = true - navigationController?.pushViewController(viewController, animated: true) - } catch { - wpAssertionFailure("Failed to fetch pages", userInfo: ["error": "\(error)"]) // This should never happen - } - } - - @objc public func getParentPageTitle() -> String? { - guard let page = (self.apost as? Page) else { - wpAssertionFailure("post has to be a page") - return nil - } - guard let pageID = page.parentID else { - return nil - } - let request = NSFetchRequest(entityName: Page.entityName()) - request.fetchLimit = 1 - request.predicate = NSPredicate(format: "postID == %@", pageID) - guard let parent = try? (page.managedObjectContext?.fetch(request))?.first else { - return nil - } - return parent.titleForDisplay() - } -} - -// MARK: - PostSettingsViewController (Misc) - -extension PostSettingsViewController { - @objc public func configureFeaturedImageCell(cell: UITableViewCell, viewModel: PostSettingsFeaturedImageViewModel) { - var configuration = UIHostingConfiguration { - PostSettingsFeaturedImageCell(post: apost, viewModel: viewModel) { [weak self] in - self?.showFeaturedImageSelector(cell: cell) - } - .environment(\.presentingViewController, self) - } - if apost.featuredImage != nil { - configuration = configuration.margins(.all, 0) - } - cell.contentConfiguration = configuration - cell.selectionStyle = .none - cell.accessibilityIdentifier = "post_settings_featured_image_cell" - } - - private func showFeaturedImageSelector(cell: UITableViewCell) { - guard let featuredImage = apost.featuredImage else { return } - let lightboxVC = LightboxViewController(media: featuredImage) - lightboxVC.configureZoomTransition(sourceView: cell.contentView) - present(lightboxVC, animated: true) - } - - @objc public func showPostAuthorSelector() { - let authorVC = PostAuthorSelectorViewController(post: apost) - authorVC.completion = { [weak authorVC] in - WPAnalytics.track(.editorPostAuthorChanged, properties: ["via": "settings"]) - authorVC?.dismiss() // It pops VC - self.tableView.reloadData() - } - navigationController?.pushViewController(authorVC, animated: true) - } - - @objc public func showTagsPicker() { - guard let post = apost as? Post else { - return wpAssertionFailure("expected post type") - } - let tagsPickerVC = PostTagPickerViewController(tags: post.tags ?? "", blog: post.blog) - tagsPickerVC.onValueChanged = { value in - WPAnalytics.track(.editorPostTagsChanged, properties: ["via": "settings"]) - post.tags = value - } - WPAnalytics.track(.postSettingsAddTagsShown) - navigationController?.pushViewController(tagsPickerVC, animated: true) - } -} - -private enum Strings { - static let warningPostWillBePublishedAlertMessage = NSLocalizedString("postSettings.warningPostWillBePublishedAlertMessage", value: "By changing the visibility to 'Private', the post will be published immediately", comment: "An alert message explaning that by changing the visibility to private, the post will be published immediately to your site") -} diff --git a/WordPress/Classes/ViewRelated/Post/PostSettingsViewController.h b/WordPress/Classes/ViewRelated/Post/PostSettingsViewController.h deleted file mode 100644 index 0276c6c0042d..000000000000 --- a/WordPress/Classes/ViewRelated/Post/PostSettingsViewController.h +++ /dev/null @@ -1,24 +0,0 @@ -#import - -@class AbstractPost; -// TODO: It can be removed when the new editor is released. It only exists to support the "Featured" badge on featured images in Gutenberg mobile. -@protocol FeaturedImageDelegate - -- (void)gutenbergDidRequestFeaturedImageId:(nonnull NSNumber *)mediaID; - -@end - -@interface PostSettingsViewController : UITableViewController - -- (nonnull instancetype)initWithPost:(nonnull AbstractPost *)aPost; - -@property (nonnull, nonatomic, strong, readonly) AbstractPost *apost; -@property (nonatomic) BOOL isStandalone; -@property (nonnull, nonatomic, strong, readonly) NSArray *publicizeConnections; -@property (nonnull, nonatomic, strong, readonly) NSArray *unsupportedConnections; - -@property (nonatomic, weak, nullable) id featuredImageDelegate; - -- (void)reloadData; - -@end diff --git a/WordPress/Classes/ViewRelated/Post/PostSettingsViewController.m b/WordPress/Classes/ViewRelated/Post/PostSettingsViewController.m deleted file mode 100644 index 1f3881ab0331..000000000000 --- a/WordPress/Classes/ViewRelated/Post/PostSettingsViewController.m +++ /dev/null @@ -1,1014 +0,0 @@ -#import "PostSettingsViewController.h" -#import "PostSettingsViewController_Internal.h" -#import "SettingsSelectionViewController.h" -#import "SharingDetailViewController.h" -#import "MediaService.h" -#import "WordPress-Swift.h" -@import WordPressData; - -@import Gridicons; -@import WordPressShared; -@import WordPressKit; -@import WordPressUI; -@import Reachability; - -typedef NS_ENUM(NSInteger, PostSettingsRow) { - PostSettingsRowCategories = 0, - PostSettingsRowTags, - PostSettingsRowAuthor, - PostSettingsRowPublishDate, - PostSettingsRowPendingReview, - PostSettingsRowVisibility, - PostSettingsRowFormat, - PostSettingsRowFeaturedImage, - PostSettingsRowShareConnection, - PostSettingsRowShareMessage, - PostSettingsRowSlug, - PostSettingsRowExcerpt, - PostSettingsRowSocialNoConnections, - PostSettingsRowSocialRemainingShares, - PostSettingsRowParentPage -}; - -static NSString *const PostSettingsAnalyticsTrackingSource = @"post_settings"; -static NSString *const TableViewFeaturedImageCellIdentifier = @"TableViewFeaturedImageCellIdentifier"; -static NSString *const TableViewToggleCellIdentifier = @"TableViewToggleCellIdentifier"; -static NSString *const TableViewGenericCellIdentifier = @"TableViewGenericCellIdentifier"; - - -@interface PostSettingsViewController () - -@property (nonatomic, strong) AbstractPost *apost; -@property (nonatomic, strong) NSArray *postMetaSectionRows; -@property (nonatomic, strong) NSArray *formatsList; - -@property (nonatomic, strong) NSArray *publicizeConnections; -@property (nonatomic, strong) NSArray *unsupportedConnections; -@property (nonatomic, strong) NSMutableArray *enabledConnections; - -@property (nonatomic, strong) NSDateFormatter *postDateFormatter; - -@property (nonatomic, strong) PostSettingsFeaturedImageViewModel *featuredImageViewModel; - -#pragma mark - Properties: Services - -@property (nonatomic, strong, readonly) BlogService *blogService; -@property (nonatomic, strong, readonly) SharingService *sharingService; - -#pragma mark - Properties: Reachability - -@property (nonatomic, strong, readwrite) Reachability *internetReachability; - -@end - -@implementation PostSettingsViewController - -#pragma mark - Initialization and dealloc - -- (void)dealloc -{ - [self.internetReachability stopNotifier]; -} - -- (instancetype)initWithPost:(AbstractPost *)aPost -{ - self = [super initWithStyle:UITableViewStyleInsetGrouped]; - if (self) { - self.apost = aPost; - self.unsupportedConnections = @[]; - self.enabledConnections = [NSMutableArray array]; - self.featuredImageViewModel = [[PostSettingsFeaturedImageViewModel alloc] initWithPost:aPost]; - } - return self; -} - -#pragma mark - UIViewController - -- (void)viewDidLoad -{ - [super viewDidLoad]; - - if ([self.apost isKindOfClass:[Page class]]) { - self.title = NSLocalizedString(@"Page Settings", @"The title of the Page Settings screen."); - } else { - self.title = NSLocalizedString(@"Post Settings", @"The title of the Post Settings screen."); - } - - DDLogInfo(@"%@ %@", self, NSStringFromSelector(_cmd)); - - [WPStyleGuide configureColorsForView:self.view andTableView:self.tableView]; - [WPStyleGuide configureAutomaticHeightRowsFor:self.tableView]; - - [self setupFormatsList]; - [self setupPublicizeConnections]; - - [self.tableView registerClass:[UITableViewCell class] forCellReuseIdentifier:TableViewFeaturedImageCellIdentifier]; - [self.tableView registerClass:[SwitchTableViewCell class] forCellReuseIdentifier:TableViewToggleCellIdentifier]; - [self.tableView registerClass:[UITableViewCell class] forCellReuseIdentifier:TableViewGenericCellIdentifier]; - - self.tableView.tableFooterView = [[UIView alloc] initWithFrame:CGRectMake(0.0, 0.0, 0.0, 44.0)]; // add some vertical padding - self.tableView.cellLayoutMarginsFollowReadableWidth = YES; - - // Compensate for the first section's height of 1.0f - self.tableView.contentInset = UIEdgeInsetsMake(-1.0f, 0, 0, 0); - self.tableView.accessibilityIdentifier = @"SettingsTable"; - - self.featuredImageViewModel.tableView = self.tableView; - self.featuredImageViewModel.delegate = self.featuredImageDelegate; - - _blogService = [[BlogService alloc] initWithCoreDataStack:[ContextManager sharedInstance]]; - - [self setupPostDateFormatter]; - - [WPAnalytics track:WPAnalyticsStatPostSettingsShown]; - - // It's recommended to keep this call near the end of the initial setup, since we don't want - // reachability callbacks to trigger before such initial setup completes. - // - [self setupReachability]; - [self onViewDidLoad]; -} - -- (void)viewWillAppear:(BOOL)animated -{ - [super viewWillAppear:animated]; - - [self.navigationController setNavigationBarHidden:NO animated:NO]; - [self.navigationController setToolbarHidden:YES]; - - [self setupPublicizeConnections]; // Refresh in case the user disconnects from unsupported services. - [self configureMetaSectionRows]; - [self reloadData]; -} - -- (void)viewDidLayoutSubviews { - [super viewDidLayoutSubviews]; - - [self.tableView sizeToFitFooterView]; -} - -- (void)didReceiveMemoryWarning -{ - DDLogWarn(@"%@ %@", self, NSStringFromSelector(_cmd)); - [super didReceiveMemoryWarning]; -} - -#pragma mark - Additional setup - -- (void)setupFormatsList -{ - self.formatsList = self.post.blog.sortedPostFormatNames; -} - -- (void)setupPublicizeConnections -{ - // Separate Twitter connections if the service is unsupported. - PublicizeService *twitterService = [PublicizeService lookupPublicizeServiceNamed:@"twitter" - inContext:self.apost.managedObjectContext]; - - if (!twitterService || [twitterService isSupported]) { - return; - } - - NSMutableArray *supportedConnections = [NSMutableArray new]; - NSMutableArray *unsupportedConnections = [NSMutableArray new]; - for (PublicizeConnection *connection in self.post.blog.sortedConnections) { - if ([connection.service isEqualToString:twitterService.serviceID]) { - [unsupportedConnections addObject:connection]; - continue; - } - - [supportedConnections addObject:connection]; - - if (![self.post publicizeConnectionDisabledForKeyringID:connection.keyringConnectionID] - && ![self.enabledConnections containsObject:connection.keyringConnectionID]) { - [self.enabledConnections addObject:connection.keyringConnectionID]; - } - } - - self.publicizeConnections = supportedConnections; - self.unsupportedConnections = unsupportedConnections; -} - -- (void)setupReachability -{ - self.internetReachability = [Reachability reachabilityForInternetConnection]; - - __weak __typeof(self) weakSelf = self; - - self.internetReachability.reachableBlock = ^void(Reachability * __unused reachability) { - [weakSelf internetIsReachableAgain]; - }; - - [self.internetReachability startNotifier]; -} - -- (void)setupPostDateFormatter -{ - NSDateFormatter *dateFormatter = [[NSDateFormatter alloc] init]; - dateFormatter.dateStyle = NSDateFormatterMediumStyle; - dateFormatter.timeStyle = NSDateFormatterShortStyle; - dateFormatter.timeZone = [self.apost.blog timeZone]; - self.postDateFormatter = dateFormatter; -} - -#pragma mark - Reachability handling - -- (void)internetIsReachableAgain -{ - [self synchUnavailableData]; -} - -- (void)synchUnavailableData -{ - __weak __typeof(self) weakSelf = self; - - if (self.formatsList.count == 0) { - [self synchPostFormatsAndDo:^{ - // DRM: if we ever start synchronizing anything else that could affect the table data - // aside from the post formats, we will need reload the table view only once all of the - // synchronization calls complete. - // - [[weakSelf tableView] reloadData]; - }]; - } -} - -- (void)synchPostFormatsAndDo:(void(^)(void))completionBlock -{ - __weak __typeof(self) weakSelf = self; - - [self.blogService syncPostFormatsForBlog:self.apost.blog success:^{ - [weakSelf setupFormatsList]; - completionBlock(); - } failure:^(NSError * _Nonnull __unused error) { - completionBlock(); - }]; -} - -// sync the latest state of Twitter. -- (void)syncPublicizeServices -{ - __weak __typeof(self) weakSelf = self; - [self.sharingService syncPublicizeServicesForBlog:self.apost.blog success:^{ - [weakSelf setupPublicizeConnections]; - } failure:nil]; -} - -#pragma mark - Instance Methods - -- (void)setApost:(AbstractPost *)apost -{ - if ([apost isEqual:_apost]) { - return; - } - _apost = apost; -} - -- (Post *)post -{ - if ([self.apost isKindOfClass:[Post class]]) { - return (Post *)self.apost; - } - - return nil; -} - -- (void)reloadData -{ - [self configureSections]; - [self.tableView reloadData]; -} - -#pragma mark - UITableView Delegate - -- (void)configureSections -{ - NSNumber *stickyPostSection = @(PostSettingsSectionStickyPost); - NSNumber *disabledTwitterSection = @(PostSettingsSectionDisabledTwitter); - NSNumber *remainingSharesSection = @(PostSettingsSectionSharesRemaining); - NSNumber *shareSection = @(PostSettingsSectionShare); - NSMutableArray *sections = [@[ @(PostSettingsSectionMeta), - @(PostSettingsSectionFeaturedImage), - @(PostSettingsSectionTaxonomy), - stickyPostSection, - shareSection, - disabledTwitterSection, - remainingSharesSection, - @(PostSettingsSectionMoreOptions) ] mutableCopy]; - // Remove sticky post section for self-hosted non Jetpack site - // and non admin user - // - if (![self.apost.blog supports:BlogFeatureWPComRESTAPI] && !self.apost.blog.isAdmin) { - [sections removeObject:stickyPostSection]; - } - - if (self.unsupportedConnections.count == 0) { - [sections removeObject:disabledTwitterSection]; - } - - if ([self numberOfRowsForShareSection] == 0) { - [sections removeObject:shareSection]; - } - - if (![self showRemainingShares]) { - [sections removeObject:remainingSharesSection]; - } - - self.sections = [sections copy]; -} - -- (NSInteger)numberOfSectionsInTableView:(UITableView *)tableView -{ - if (!self.sections) { - [self configureSections]; - } - return [self.sections count]; -} - -- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section -{ - NSInteger sec = [[self.sections objectAtIndex:section] integerValue]; - if (sec == PostSettingsSectionTaxonomy) { - return 2; - } else if (sec == PostSettingsSectionMeta) { - return [self.postMetaSectionRows count]; - } else if (sec == PostSettingsSectionFeaturedImage) { - return 1; - } else if (sec == PostSettingsSectionStickyPost) { - return 1; - } else if (sec == PostSettingsSectionShare) { - return [self numberOfRowsForShareSection]; - } else if (sec == PostSettingsSectionDisabledTwitter) { - return self.unsupportedConnections.count; - } else if (sec == PostSettingsSectionSharesRemaining) { - return 1; - } else if (sec == PostSettingsSectionMoreOptions) { - return 3; - } else if (sec == PostSettingsSectionPageAttributes) { - return 1; - } - - return 0; -} - -- (NSString *)tableView:(UITableView *)tableView titleForHeaderInSection:(NSInteger)section -{ - NSInteger sec = [[self.sections objectAtIndex:section] integerValue]; - if (sec == PostSettingsSectionTaxonomy) { - return NSLocalizedString(@"Taxonomy", @"Label for the Taxonomy area (categories, keywords, ...) in post settings."); - - } else if (sec == PostSettingsSectionMeta) { - return NSLocalizedString(@"Publish", @"Label for the publish (verb) button. Tapping publishes a draft post."); - - } else if (sec == PostSettingsSectionFeaturedImage) { - return NSLocalizedString(@"Featured Image", @"Label for the Featured Image area in post settings."); - - } else if (sec == PostSettingsSectionStickyPost) { - return NSLocalizedString(@"Mark as Sticky", @"Label for the Mark as Sticky option in post settings."); - - } else if (sec == PostSettingsSectionShare && [self numberOfRowsForShareSection] > 0) { - return NSLocalizedString(@"Jetpack Social", @"Label for the Sharing section in post Settings. Should be the same as WP core."); - - } else if (sec == PostSettingsSectionDisabledTwitter) { - return NSLocalizedStringWithDefaultValue(@"postSettings.section.disabledTwitter.header", - nil, - [NSBundle mainBundle], - @"Twitter Auto-Sharing Is No Longer Available", - @"Section title for the disabled Twitter service in the Post Settings screen"); - - } else if (sec == PostSettingsSectionMoreOptions) { - return NSLocalizedString(@"More Options", @"Label for the More Options area in post settings. Should use the same translation as core WP."); - - } else if (sec == PostSettingsSectionPageAttributes) { - return NSLocalizedStringWithDefaultValue(@"postSettings.section.pageAttributes", nil, [NSBundle mainBundle], @"Page Attributes", @"Section title for Page Attributes"); - } - return nil; -} - -- (UIView *)tableView:(UITableView *)tableView viewForFooterInSection:(NSInteger)section -{ - NSInteger sec = [[self.sections objectAtIndex:section] integerValue]; - if (sec == PostSettingsSectionDisabledTwitter) { - TwitterDeprecationTableFooterView *footerView = [[TwitterDeprecationTableFooterView alloc] init]; - footerView.presentingViewController = self; - footerView.source = @"post_settings"; - - return footerView; - } - - return nil; -} - -- (CGFloat)tableView:(UITableView *)tableView heightForHeaderInSection:(NSInteger)section -{ - if ([self tableView:tableView numberOfRowsInSection:section] == 0) { - return CGFLOAT_MIN; - } else { - return UITableViewAutomaticDimension; - } -} - -- (CGFloat)tableView:(UITableView *)tableView heightForFooterInSection:(NSInteger)section -{ - if ([self tableView:tableView numberOfRowsInSection:section] == 0) { - return CGFLOAT_MIN; - } else { - return UITableViewAutomaticDimension; - } -} - -- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath -{ - return UITableViewAutomaticDimension; -} - -- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath -{ - NSInteger sec = [[self.sections objectAtIndex:indexPath.section] integerValue]; - - UITableViewCell *cell; - - if (sec == PostSettingsSectionTaxonomy) { - cell = [self configureTaxonomyCellForIndexPath:indexPath]; - } else if (sec == PostSettingsSectionMeta) { - cell = [self configureMetaPostMetaCellForIndexPath:indexPath]; - } else if (sec == PostSettingsSectionFeaturedImage) { - cell = [self makeFeaturedImageCellForIndexPath:indexPath]; - } else if (sec == PostSettingsSectionStickyPost) { - cell = [self configureStickyPostCellForIndexPath:indexPath]; - } else if (sec == PostSettingsSectionShare || sec == PostSettingsSectionDisabledTwitter) { - cell = [self showNoConnection] ? [self configureNoConnectionCell] : [self configureShareCellForIndexPath:indexPath]; - } else if (sec == PostSettingsSectionSharesRemaining) { - cell = [self configureRemainingSharesCell]; - } else if (sec == PostSettingsSectionMoreOptions) { - cell = [self configureMoreOptionsCellForIndexPath:indexPath]; - } else if (sec == PostSettingsSectionPageAttributes) { - cell = [self configurePageAttributesCellForIndexPath:indexPath]; - } - - return cell ?: [UITableViewCell new]; -} - -- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath -{ - [tableView deselectRowAtIndexPath:[self.tableView indexPathForSelectedRow] animated:YES]; - - UITableViewCell *cell = [tableView cellForRowAtIndexPath:indexPath]; - NSInteger sec = [[self.sections objectAtIndex:indexPath.section] integerValue]; - - if (cell.tag == PostSettingsRowCategories) { - [self showCategoriesSelection]; - } else if (cell.tag == PostSettingsRowTags) { - [self showTagsPicker]; - } else if (cell.tag == PostSettingsRowPublishDate) { - [self showPublishDatePicker]; - } else if (cell.tag == PostSettingsRowVisibility) { - [self showPostVisibilitySelector]; - } else if (cell.tag == PostSettingsRowAuthor) { - [self showPostAuthorSelector]; - } else if (cell.tag == PostSettingsRowFormat) { - [self showPostFormatSelector]; - } else if (sec == PostSettingsSectionDisabledTwitter) { - [self showShareDetailForIndexPath:indexPath]; - } else if (cell.tag == PostSettingsRowShareConnection) { - [self toggleShareConnectionForIndexPath:indexPath]; - } else if (cell.tag == PostSettingsRowShareMessage) { - [self showEditShareMessageController]; - } else if (cell.tag == PostSettingsRowSlug) { - [self showEditSlugController]; - } else if (cell.tag == PostSettingsRowExcerpt) { - [self showEditExcerptController]; - } else if (cell.tag == PostSettingsRowParentPage) { - [self showParentPageController]; - } -} - -- (NSInteger)numberOfRowsForShareSection -{ - if ([self.apost.status isEqualToString:@"private"]) { - return 0; - } - - if (self.apost.blog.supportsPublicize && self.publicizeConnections.count > 0) { - // One row per publicize connection plus an extra row for the publicze message - return self.publicizeConnections.count + 1; - } - return [self showNoConnection] ? 1 : 0; -} - -- (UITableViewCell *)configureTaxonomyCellForIndexPath:(NSIndexPath *)indexPath -{ - UITableViewCell *cell = [self getWPTableViewDisclosureCell]; - - if (indexPath.row == PostSettingsRowCategories) { - // Categories - cell.textLabel.text = NSLocalizedString(@"Categories", @"Label for the categories field. Should be the same as WP core."); - cell.detailTextLabel.text = [NSString decodeXMLCharactersIn:[self.post categoriesText]]; - cell.tag = PostSettingsRowCategories; - cell.accessibilityIdentifier = @"Categories"; - - } else if (indexPath.row == PostSettingsRowTags) { - // Tags - cell.textLabel.text = NSLocalizedString(@"Tags", @"Label for the tags field. Should be the same as WP core."); - cell.detailTextLabel.text = self.post.tags; - cell.tag = PostSettingsRowTags; - cell.accessibilityIdentifier = @"Tags"; - } - - return cell; -} - -- (void)configureMetaSectionRows -{ - NSMutableArray *metaRows = [[NSMutableArray alloc] init]; - - if (self.apost.isMultiAuthorBlog) { - [metaRows addObject:@(PostSettingsRowAuthor)]; - } - - if (self.isDraftOrPending) { - [metaRows addObject:@(PostSettingsRowPendingReview)]; - } else { - [metaRows addObjectsFromArray:@[ - @(PostSettingsRowPublishDate), - @(PostSettingsRowVisibility) - ]]; - } - - self.postMetaSectionRows = [metaRows copy]; -} - -- (UITableViewCell *)configureMetaPostMetaCellForIndexPath:(NSIndexPath *)indexPath -{ - UITableViewCell *cell; - NSInteger row = [[self.postMetaSectionRows objectAtIndex:indexPath.row] integerValue]; - - if (row == PostSettingsRowAuthor) { - // Author - cell = [self getWPTableViewDisclosureCell]; - cell.textLabel.text = NSLocalizedString(@"Author", @"The author of the post or page."); - cell.accessibilityIdentifier = @"SetAuthor"; - cell.detailTextLabel.text = [self.apost authorNameForDisplay]; - cell.tag = PostSettingsRowAuthor; - } else if (row == PostSettingsRowPublishDate) { - // Publish date - cell = [self getWPTableViewDisclosureCellWithIdentifier:@"PostSettingsRowPublishDate"]; - cell.textLabel.text = NSLocalizedString(@"Publish Date", @"Label for the publish date button."); - if (self.apost.dateCreated) { - cell.detailTextLabel.text = [self.postDateFormatter stringFromDate:self.apost.dateCreated]; - } else { - // Should never happen as this field is displayed only for published/scheduled posts - cell.detailTextLabel.text = @""; - } - - if ([self.apost.status isEqualToString:PostStatusPrivate]) { - [cell disable]; - } else { - [cell enable]; - cell.accessoryType = UITableViewCellAccessoryDisclosureIndicator; - } - - cell.tag = PostSettingsRowPublishDate; - } else if (row == PostSettingsRowVisibility) { - // Visibility - cell = [self getWPTableViewDisclosureCellWithIdentifier:@"PostSettingsRowVisibility"]; - cell.textLabel.text = NSLocalizedString(@"Visibility", @"The visibility settings of the post. Should be the same as in core WP."); - cell.detailTextLabel.text = [self.apost titleForVisibility]; - cell.tag = PostSettingsRowVisibility; - cell.accessibilityIdentifier = @"Visibility"; - - } else if (row == PostSettingsRowPendingReview) { - // Pending Review - __weak __typeof(self) weakSelf = self; - SwitchTableViewCell *cell = [self.tableView dequeueReusableCellWithIdentifier:TableViewToggleCellIdentifier]; - cell.name = NSLocalizedStringWithDefaultValue(@"postSettings.pendingReview", nil, [NSBundle mainBundle], @"Pending review", @"The 'Pending Review' setting of the post"); - cell.on = [self.post.status isEqualToString:PostStatusPending]; - cell.onChange = ^(BOOL newValue) { - [WPAnalytics trackEvent:WPAnalyticsEventEditorPostPendingReviewChanged properties:@{@"via": @"settings"}]; - weakSelf.post.status = newValue ? PostStatusPending : PostStatusDraft; - }; - return cell; - } - - return cell; -} - -- (UITableViewCell *)configurePostFormatCellForIndexPath:(NSIndexPath *)indexPath -{ - UITableViewCell *cell = [self getWPTableViewDisclosureCell]; - - cell.textLabel.text = NSLocalizedString(@"Post Format", @"The post formats available for the post. Should be the same as in core WP."); - - if (self.post.postFormatText.length > 0) { - cell.detailTextLabel.text = self.post.postFormatText; - } else { - cell.detailTextLabel.text = NSLocalizedString(@"Unavailable", - @"Message to show in the post-format cell when the post format is not available"); - } - - cell.tag = PostSettingsRowFormat; - cell.accessibilityIdentifier = @"Post Format"; - return cell; -} - -- (UITableViewCell *)makeFeaturedImageCellForIndexPath:(NSIndexPath *)indexPath -{ - UITableViewCell *cell = [self.tableView dequeueReusableCellWithIdentifier:TableViewFeaturedImageCellIdentifier forIndexPath:indexPath]; - [self configureFeaturedImageCellWithCell:cell viewModel:self.featuredImageViewModel]; - cell.tag = PostSettingsRowFeaturedImage; - return cell; -} - -- (UITableViewCell *)configureStickyPostCellForIndexPath:(NSIndexPath *)indexPath -{ - __weak __typeof(self) weakSelf = self; - - SwitchTableViewCell *cell = [self.tableView dequeueReusableCellWithIdentifier:TableViewToggleCellIdentifier]; - cell.name = NSLocalizedString(@"Stick post to the front page", @"This is the cell title."); - cell.on = self.post.isStickyPost; - cell.onChange = ^(BOOL newValue) { - [WPAnalytics trackEvent:WPAnalyticsEventEditorPostStickyChanged properties:@{@"via": @"settings"}]; - weakSelf.post.isStickyPost = newValue; - }; - return cell; -} - -- (UITableViewCell *)configureSocialCellForIndexPath:(NSIndexPath *)indexPath - connection:(PublicizeConnection *)connection - canEditSharing:(BOOL)canEditSharing - section:(NSInteger)section -{ - UITableViewCell *cell = [self getWPTableViewImageAndAccessoryCell]; - UIImage *image = [[WPStyleGuide socialIconFor:connection.service] resizedTo:CGSizeMake(28.0, 28.0) format: ScalingModeScaleAspectFill]; - [cell.imageView setImage:image]; - cell.imageView.alpha = 1.0; - if (!canEditSharing) { - cell.imageView.alpha = 0.36; - } - cell.textLabel.text = connection.externalDisplay; - cell.textLabel.enabled = canEditSharing; - if (connection.isBroken) { - cell.accessoryView = section == PostSettingsSectionShare ? - [WPStyleGuide sharingCellWarningAccessoryImageView] : - [WPStyleGuide sharingCellErrorAccessoryImageView]; - } else { - UISwitch *switchAccessory = [[UISwitch alloc] initWithFrame:CGRectZero]; - // This interaction is handled at a cell level - switchAccessory.userInteractionEnabled = NO; - switchAccessory.on = ![self.post publicizeConnectionDisabledForKeyringID:connection.keyringConnectionID]; - switchAccessory.enabled = canEditSharing; - cell.accessoryView = switchAccessory; - } - cell.selectionStyle = UITableViewCellSelectionStyleNone; - cell.tag = PostSettingsRowShareConnection; - cell.accessibilityIdentifier = [NSString stringWithFormat:@"%@ %@", connection.service, connection.externalDisplay]; - return cell; -} - -- (UITableViewCell *)configureDisclosureCellWithSharing:(BOOL)canEditSharing -{ - UITableViewCell *cell = [self getWPTableViewDisclosureCell]; - cell.textLabel.text = NSLocalizedString(@"Message", @"Label for the share message field on the post settings."); - cell.textLabel.enabled = canEditSharing; - cell.detailTextLabel.text = self.post.publicizeMessage ? self.post.publicizeMessage : self.post.titleForDisplay; - cell.detailTextLabel.enabled = canEditSharing; - cell.tag = PostSettingsRowShareMessage; - cell.accessibilityIdentifier = @"Customize the message"; - return cell; -} - -- (UITableViewCell *)configureShareCellForIndexPath:(NSIndexPath *)indexPath -{ - UITableViewCell *cell; - BOOL canEditSharing = [self userCanEditSharing]; - NSInteger sec = [[self.sections objectAtIndex:indexPath.section] integerValue]; - NSArray *connections = sec == PostSettingsSectionShare ? self.publicizeConnections : self.unsupportedConnections; - - if (indexPath.row < connections.count) { - PublicizeConnection *connection = connections[indexPath.row]; - if ([RemoteFeature enabled:RemoteFeatureFlagJetpackSocialImprovements]) { - BOOL hasRemainingShares = self.enabledConnections.count < [self remainingSocialShares]; - BOOL isSwitchOn = ![self.post publicizeConnectionDisabledForKeyringID:connection.keyringConnectionID]; - canEditSharing = canEditSharing && (hasRemainingShares || isSwitchOn); - } - cell = [self configureSocialCellForIndexPath:indexPath - connection:connection - canEditSharing:canEditSharing - section:sec]; - } else { - cell = [self configureDisclosureCellWithSharing:canEditSharing]; - } - cell.userInteractionEnabled = canEditSharing; - return cell; -} - -- (UITableViewCell *)configureMoreOptionsCellForIndexPath:(NSIndexPath *)indexPath -{ - if (indexPath.row == 0) { - return [self configurePostFormatCellForIndexPath:indexPath]; - } else if (indexPath.row == 1) { - return [self configureSlugCellForIndexPath:indexPath]; - } else { - return [self configureExcerptCellForIndexPath:indexPath]; - } -} - -- (UITableViewCell *)configureSlugCellForIndexPath:(NSIndexPath *)indexPath -{ - WPTableViewCell *cell = [self getWPTableViewDisclosureCell]; - cell.textLabel.text = NSLocalizedString(@"Slug", @"Label for the slug field. Should be the same as WP core."); - cell.detailTextLabel.text = self.apost.slugForDisplay; - cell.tag = PostSettingsRowSlug; - cell.accessibilityIdentifier = @"Slug"; - return cell; -} - -- (UITableViewCell *)configureExcerptCellForIndexPath:(NSIndexPath *)indexPath -{ - WPTableViewCell *cell = [self getWPTableViewDisclosureCell]; - cell.textLabel.text = NSLocalizedString(@"Excerpt", @"Label for the excerpt field. Should be the same as WP core."); - cell.detailTextLabel.text = self.apost.mt_excerpt; - cell.tag = PostSettingsRowExcerpt; - cell.accessibilityIdentifier = @"Excerpt"; - return cell; -} - -- (WPTableViewCell *)getWPTableViewDisclosureCell { - return [self getWPTableViewDisclosureCellWithIdentifier:@"WPTableViewDisclosureCellIdentifier"]; -} - -- (WPTableViewCell *)getWPTableViewDisclosureCellWithIdentifier:(NSString *)identifier -{ - WPTableViewCell *cell = [self.tableView dequeueReusableCellWithIdentifier:identifier]; - if (!cell) { - cell = [[WPTableViewCell alloc] initWithStyle:UITableViewCellStyleValue1 reuseIdentifier:identifier]; - cell.accessoryType = UITableViewCellAccessoryDisclosureIndicator; - [WPStyleGuide configureTableViewCell:cell]; - } - cell.tag = 0; - return cell; -} - -- (WPTableViewCell *)getWPTableViewImageAndAccessoryCell -{ - static NSString *WPTableViewImageAndAccesoryCellIdentifier = @"WPTableViewImageAndAccesoryCellIdentifier"; - WPTableViewCell *cell = [self.tableView dequeueReusableCellWithIdentifier:WPTableViewImageAndAccesoryCellIdentifier]; - if (!cell) { - cell = [[WPTableViewCell alloc] initWithStyle:UITableViewCellStyleValue1 reuseIdentifier:WPTableViewImageAndAccesoryCellIdentifier]; - [WPStyleGuide configureTableViewCell:cell]; - } - cell.accessoryView = nil; - cell.imageView.image = nil; - cell.tag = 0; - return cell; -} - -- (void)showPostFormatSelector -{ - Post *post = self.post; - NSArray *titles = post.blog.sortedPostFormatNames; - - if (![self.internetReachability isReachable] && self.formatsList.count == 0) { - [self showCantShowPostFormatsAlert]; - return; - } - - if (post == nil || titles.count == 0 || post.postFormatText == nil || self.formatsList.count == 0) { - return; - } - NSDictionary *(^postFormatsDictionary)(NSArray *) = ^NSDictionary *(NSArray *titles) { - return @{ - SettingsSelectionDefaultValueKey : [titles firstObject], - SettingsSelectionTitleKey : NSLocalizedString(@"Post Format", nil), - SettingsSelectionTitlesKey : titles, - SettingsSelectionValuesKey : titles, - SettingsSelectionCurrentValueKey : post.postFormatText - };; - }; - - SettingsSelectionViewController *vc = [[SettingsSelectionViewController alloc] initWithDictionary:postFormatsDictionary(titles)]; - __weak SettingsSelectionViewController *weakVc = vc; - __weak __typeof(self) weakSelf = self; - __weak Post *weakPost = post; - vc.onItemSelected = ^(NSString *status) { - // Check if the object passed is indeed an NSString, otherwise we don't want to try to set it as the post format - if ([status isKindOfClass:[NSString class]]) { - [WPAnalytics trackEvent:WPAnalyticsEventEditorPostFormatChanged properties:@{@"via": @"settings"}]; - post.postFormatText = status; - [weakVc dismiss]; - [self.tableView reloadData]; - } - }; - vc.onRefresh = ^(UIRefreshControl *refreshControl) { - [weakSelf synchPostFormatsAndDo:^{ - NSArray *titles = weakPost.blog.sortedPostFormatNames; - if (titles.count) { - [weakVc reloadWithDictionary:postFormatsDictionary(titles)]; - } - [refreshControl endRefreshing]; - }]; - }; - vc.invokesRefreshOnViewWillAppear = YES; - [self.navigationController pushViewController:vc animated:YES]; -} - -- (void)showCantShowPostFormatsAlert -{ - NSString *title = NSLocalizedString(@"Connection not available", - @"Title of a prompt saying the app needs an internet connection before it can load post formats"); - - NSString *message = NSLocalizedString(@"Please check your internet connection and try again.", - @"Politely asks the user to check their internet connection before trying again. "); - - NSString *cancelButtonTitle = NSLocalizedString(@"OK", @"Title of a button that dismisses a prompt"); - - UIAlertController *alertController = [UIAlertController alertControllerWithTitle:title - message:message - preferredStyle:UIAlertControllerStyleAlert]; - - [alertController addCancelActionWithTitle:cancelButtonTitle handler:nil]; - - [alertController presentFromRootViewController]; -} - -- (void)toggleShareConnectionForIndexPath:(NSIndexPath *) indexPath -{ - UITableViewCell *cell = [self.tableView cellForRowAtIndexPath:indexPath]; - BOOL isJetpackSocialEnabled = [RemoteFeature enabled:RemoteFeatureFlagJetpackSocialImprovements]; - if (indexPath.row < self.publicizeConnections.count) { - PublicizeConnection *connection = self.publicizeConnections[indexPath.row]; - if (connection.isBroken) { - SharingDetailViewController *controller = [[SharingDetailViewController alloc] initWithBlog:self.post.blog - publicizeConnection:connection]; - [self.navigationController pushViewController:controller animated:YES]; - } else { - UISwitch *cellSwitch = (UISwitch *)cell.accessoryView; - [cellSwitch setOn:!cellSwitch.on animated:YES]; - if (cellSwitch.on) { - [self.post enablePublicizeConnectionWithKeyringID:connection.keyringConnectionID]; - - if (isJetpackSocialEnabled) { - [self.enabledConnections addObject:connection.keyringConnectionID]; - [self reloadSocialSectionComparingValue:[self remainingSocialShares]]; - } - } else { - [self.post disablePublicizeConnectionWithKeyringID:connection.keyringConnectionID]; - - if (isJetpackSocialEnabled) { - [self.enabledConnections removeObject:connection.keyringConnectionID]; - [self reloadSocialSectionComparingValue:[self remainingSocialShares] - 1]; - } - } - if (isJetpackSocialEnabled) { - [WPAnalytics trackEvent:WPAnalyticsEventJetpackSocialConnectionToggled - properties:@{@"source": PostSettingsAnalyticsTrackingSource, - @"value": cellSwitch.on ? @"true" : @"false"}]; - } - } - } -} - -- (void)showShareDetailForIndexPath:(NSIndexPath *)indexPath -{ - if (indexPath.row >= self.unsupportedConnections.count) { - return; - } - - PublicizeConnection *connection = self.unsupportedConnections[indexPath.row]; - SharingDetailViewController *controller = [[SharingDetailViewController alloc] initWithBlog:self.apost.blog - publicizeConnection:connection]; - [self.navigationController pushViewController:controller animated:YES]; -} - -- (void)showEditShareMessageController -{ - NSString *text = !self.post.publicizeMessage ? self.post.titleForDisplay : self.post.publicizeMessage; - - SettingsMultiTextViewController *vc = [[SettingsMultiTextViewController alloc] initWithText:text - placeholder:nil - hint:NSLocalizedString(@"Customize the message you want to share.\nIf you don't add your own text here, we'll use the post's title as the message.", @"Hint displayed when the user is customizing the share message.") - isPassword:NO]; - vc.title = NSLocalizedString(@"Customize the message", @"Title for the edition of the share message."); - vc.onValueChanged = ^(NSString *value) { - if (value.length) { - self.post.publicizeMessage = value; - } else { - self.post.publicizeMessage = nil; - } - [self.tableView reloadData]; - }; - [self.navigationController pushViewController:vc animated:YES]; -} - -- (void)showEditSlugController -{ - SettingsMultiTextViewController *vc = [[SettingsMultiTextViewController alloc] initWithText:self.apost.slugForDisplay - placeholder:nil - hint:NSLocalizedString(@"The slug is the URL-friendly version of the post title.", @"Should be the same as the text displayed if the user clicks the (i) in Slug in Calypso.") - isPassword:NO]; - vc.title = NSLocalizedString(@"Slug", @"Label for the slug field. Should be the same as WP core."); - vc.autocapitalizationType = UITextAutocapitalizationTypeNone; - vc.onValueChanged = ^(NSString *value) { - [WPAnalytics trackEvent:WPAnalyticsEventEditorPostSlugChanged properties:@{@"via": @"settings"}]; - self.apost.wp_slug = value; - [self.tableView reloadData]; - }; - [self.navigationController pushViewController:vc animated:YES]; -} - -- (void)showEditExcerptController -{ - SettingsMultiTextViewController *vc = [[SettingsMultiTextViewController alloc] initWithText:self.apost.mt_excerpt - placeholder:nil - hint:NSLocalizedString(@"Excerpts are optional hand-crafted summaries of your content.", @"Should be the same as the text displayed if the user clicks the (i) in Calypso.") - isPassword:NO]; - vc.title = NSLocalizedString(@"Excerpt", @"Label for the excerpt field. Should be the same as WP core."); - vc.onValueChanged = ^(NSString *value) { - if (self.apost.mt_excerpt != value) { - [WPAnalytics trackEvent:WPAnalyticsEventEditorPostExcerptChanged properties:@{@"via": @"settings"}]; - } - - self.apost.mt_excerpt = value; - [self.tableView reloadData]; - }; - [self.navigationController pushViewController:vc animated:YES]; -} - -- (void)showCategoriesSelection -{ - PostCategoriesViewController *controller = [[PostCategoriesViewController alloc] initWithBlog:self.post.blog - currentSelection:[self.post.categories allObjects] - selectionMode:CategoriesSelectionModePost]; - controller.delegate = self; - [self.navigationController pushViewController:controller animated:YES]; -} - -#pragma mark - Jetpack Social - -- (UITableViewCell *)configureGenericCellWith:(UIView *)view { - UITableViewCell *cell = [self.tableView dequeueReusableCellWithIdentifier:TableViewGenericCellIdentifier]; - for (UIView *subview in cell.contentView.subviews) { - [subview removeFromSuperview]; - } - [cell.contentView addSubview:view]; - [cell.contentView pinSubviewToAllEdges:view]; - return cell; -} - -- (UITableViewCell *)configureNoConnectionCell -{ - UITableViewCell *cell = [self configureGenericCellWith:[self createNoConnectionView]]; - cell.tag = PostSettingsRowSocialNoConnections; - return cell; -} - -- (UITableViewCell *)configureRemainingSharesCell -{ - UITableViewCell *cell = [self configureGenericCellWith:[self createRemainingSharesView]]; - cell.tag = PostSettingsRowSocialRemainingShares; - return cell; -} - -- (void)reloadSocialSectionComparingValue:(NSUInteger)value -{ - if (self.enabledConnections.count == value) { - NSUInteger sharingSection = [self.sections indexOfObject:@(PostSettingsSectionShare)]; - NSIndexSet *sharingSectionSet = [NSIndexSet indexSetWithIndex:sharingSection]; - [self.tableView reloadSections:sharingSectionSet withRowAnimation:UITableViewRowAnimationNone]; - } -} - -// MARK: - Page Attributes - -- (UITableViewCell *)configurePageAttributesCellForIndexPath:(NSIndexPath *)indexPath -{ - return [self configureParentPageCell]; -} - -- (UITableViewCell *)configureParentPageCell -{ - UITableViewCell *cell = [self getWPTableViewDisclosureCell]; - cell.textLabel.text = NSLocalizedStringWithDefaultValue(@"postSettings.parentPage", nil, [NSBundle mainBundle], @"Parent page", @"The 'Parent Page' setting of the post"); - cell.detailTextLabel.text = [self getParentPageTitle]; - cell.tag = PostSettingsRowParentPage; - cell.accessibilityIdentifier = @"Parent"; - return cell; -} - -#pragma mark - PostCategoriesViewControllerDelegate - -- (void)postCategoriesViewController:(PostCategoriesViewController *)controller didUpdateSelectedCategories:(NSSet *)categories -{ - [WPAnalytics trackEvent:WPAnalyticsEventEditorPostCategoryChanged properties:@{@"via": @"settings"}]; - - // Save changes. - self.post.categories = [categories mutableCopy]; - if (!self.isStandalone) { - [self.post save]; - } -} - -@end diff --git a/WordPress/Classes/ViewRelated/Post/PostSettingsViewController_Internal.h b/WordPress/Classes/ViewRelated/Post/PostSettingsViewController_Internal.h deleted file mode 100644 index 7be46a2547ac..000000000000 --- a/WordPress/Classes/ViewRelated/Post/PostSettingsViewController_Internal.h +++ /dev/null @@ -1,20 +0,0 @@ -#import "PostSettingsViewController.h" - -typedef enum { - PostSettingsSectionTaxonomy = 0, - PostSettingsSectionMeta, - PostSettingsSectionFeaturedImage, - PostSettingsSectionShare, - PostSettingsSectionStickyPost, - PostSettingsSectionDisabledTwitter, // NOTE: Clean up when Twitter has been removed from Publicize services. - PostSettingsSectionSharesRemaining, - PostSettingsSectionGeolocation, - PostSettingsSectionMoreOptions, - PostSettingsSectionPageAttributes -} PostSettingsSection; - -@interface PostSettingsViewController () - -@property (nonnull, nonatomic, strong) NSArray *sections; - -@end diff --git a/WordPress/Classes/ViewRelated/Post/PostVisibilityPicker.swift b/WordPress/Classes/ViewRelated/Post/PostVisibilityPicker.swift index 00e71fdbff11..d982356afb8c 100644 --- a/WordPress/Classes/ViewRelated/Post/PostVisibilityPicker.swift +++ b/WordPress/Classes/ViewRelated/Post/PostVisibilityPicker.swift @@ -7,6 +7,7 @@ struct PostVisibilityPicker: View { @State private var previousSelection: Selection @State private var isDismissing = false @FocusState private var isPasswordFieldFocused: Bool + @Environment(\.dismiss) private var dismiss struct Selection { var type: PostVisibility @@ -19,12 +20,14 @@ struct PostVisibilityPicker: View { } private let onSubmit: (Selection) -> Void + private let dismissOnSelection: Bool static var title: String { Strings.title } - init(selection: Selection, onSubmit: @escaping (Selection) -> Void) { + init(selection: Selection, dismissOnSelection: Bool = false, onSubmit: @escaping (Selection) -> Void) { self._selection = State(initialValue: selection) self._previousSelection = State(initialValue: selection) + self.dismissOnSelection = dismissOnSelection self.onSubmit = onSubmit } @@ -89,6 +92,9 @@ struct PostVisibilityPicker: View { isPasswordFieldFocused = true } else { onSubmit(selection) + if dismissOnSelection { + dismiss() + } } } } @@ -110,6 +116,9 @@ struct PostVisibilityPicker: View { // Let the keyboard dismiss first to avoid janky animation DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(550)) { onSubmit(selection) + if dismissOnSelection { + dismiss() + } } } else { selection = previousSelection diff --git a/WordPress/Classes/ViewRelated/Post/Prepublishing/PrepublishingAutoSharingView.swift b/WordPress/Classes/ViewRelated/Post/Prepublishing/PrepublishingAutoSharingView.swift index 3dde34bf264e..9aed6f1e1f77 100644 --- a/WordPress/Classes/ViewRelated/Post/Prepublishing/PrepublishingAutoSharingView.swift +++ b/WordPress/Classes/ViewRelated/Post/Prepublishing/PrepublishingAutoSharingView.swift @@ -45,6 +45,7 @@ struct PrepublishingAutoSharingView: View { remainingSharesLabel(text: text, showsWarning: model.showsWarning) } } + .lineLimit(1) } @ViewBuilder @@ -66,7 +67,7 @@ struct PrepublishingAutoSharingView: View { } private var socialIconsView: some View { - HStack(spacing: -2.0) { + HStack(spacing: -6) { ForEach(model.services, id: \.self) { service in iconImage(service.name.localIconImage, opaque: service.usesOpaqueIcon) } diff --git a/WordPress/Classes/ViewRelated/Post/Prepublishing/PrepublishingViewController+JetpackSocial.swift b/WordPress/Classes/ViewRelated/Post/Prepublishing/PrepublishingViewController+JetpackSocial.swift index a05a8f7b1954..dc515c2c01d8 100644 --- a/WordPress/Classes/ViewRelated/Post/Prepublishing/PrepublishingViewController+JetpackSocial.swift +++ b/WordPress/Classes/ViewRelated/Post/Prepublishing/PrepublishingViewController+JetpackSocial.swift @@ -89,7 +89,7 @@ private extension PrepublishingViewController { var hasExistingConnections: Bool { coreDataStack.performQuery { [postObjectID = post.objectID] context in guard let post = (try? context.existingObject(with: postObjectID)) as? Post, - let connections = post.blog.connections as? Set else { + let connections = post.blog.connections else { return false } return !connections.isEmpty @@ -198,10 +198,10 @@ private extension PrepublishingViewController { func makeAutoSharingModel() -> PrepublishingAutoSharingModel { return coreDataStack.performQuery { [postObjectID = post.objectID] context in guard let post = (try? context.existingObject(with: postObjectID)) as? Post, - let connections = post.blog.sortedConnections as? [PublicizeConnection], let supportedServices = try? PublicizeService.allSupportedServices(in: context) else { return .init(services: [], message: String(), sharingLimit: nil) } + let connections = post.blog.sortedConnections // first, build a dictionary to categorize the connections. var connectionsMap = [PublicizeService.ServiceName: [PublicizeConnection]]() @@ -283,25 +283,27 @@ extension PrepublishingViewController: PrepublishingSocialAccountsDelegate { } func didFinish(with connectionChanges: [Int: Bool], message: String?) { - coreDataStack.performAndSave({ [postObjectID = post.objectID] context in - guard let post = (try? context.existingObject(with: postObjectID)) as? Post else { - return - } + DispatchQueue.main.async { + self._didFinish(with: connectionChanges, message: message) + } + } - connectionChanges.forEach { (keyringID, enabled) in - if enabled { - post.enablePublicizeConnectionWithKeyringID(NSNumber(value: keyringID)) - } else { - post.disablePublicizeConnectionWithKeyringID(NSNumber(value: keyringID)) - } + private func _didFinish(with connectionChanges: [Int: Bool], message: String?) { + guard let post = post as? Post else { + wpAssertionFailure("invalid post type") + return + } + connectionChanges.forEach { (keyringID, enabled) in + if enabled { + post.enablePublicizeConnectionWithKeyringID(NSNumber(value: keyringID)) + } else { + post.disablePublicizeConnectionWithKeyringID(NSNumber(value: keyringID)) } + } - let isMessageEmpty = message?.isEmpty ?? true - post.publicizeMessage = isMessageEmpty ? nil : message + let isMessageEmpty = message?.isEmpty ?? true + post.publicizeMessage = isMessageEmpty ? nil : message - }, completion: { [weak self] in - self?.reloadData() - }, on: .main) + reloadData() } - } diff --git a/WordPress/Classes/ViewRelated/Post/Scheduling/PublishDatePickerViewController.swift b/WordPress/Classes/ViewRelated/Post/Scheduling/PublishDatePickerViewController.swift index 5fc10d4e1b0c..0fe4c8250e0c 100644 --- a/WordPress/Classes/ViewRelated/Post/Scheduling/PublishDatePickerViewController.swift +++ b/WordPress/Classes/ViewRelated/Post/Scheduling/PublishDatePickerViewController.swift @@ -39,17 +39,6 @@ final class PublishDatePickerViewController: UIHostingController Void) -> PublishDatePickerViewController { - PublishDatePickerViewController(configuration: .init( - date: viewModel.date, - isRequired: viewModel.isRequired, - timeZone: viewModel.timeZone, - updated: onDateUpdated - )) - } -} - struct PublishDatePickerView: View { @State var configuration: PublishDatePickerConfiguration @@ -63,10 +52,9 @@ struct PublishDatePickerView: View { } } header: { Color.clear.frame(height: 0) // Reducing the top inset - } - if !configuration.isCurrentTimeZone { - Section { - timeZoneRow + } footer: { + if !configuration.isCurrentTimeZone { + timeZoneFooter } } } @@ -102,14 +90,18 @@ struct PublishDatePickerView: View { } } - private var timeZoneRow: some View { - HStack { - Text(Strings.timeZone) - Spacer() + private var timeZoneFooter: some View { + HStack(spacing: 4) { + Text(Strings.timeZone + ":") + .font(.footnote.weight(.medium)) Text(getLocalizedTimeZoneDescription(for: configuration.timeZone)) + .font(.footnote) .truncationMode(.middle) - .foregroundStyle(.secondary) - }.lineLimit(1) + } + .frame(maxWidth: .infinity) + .multilineTextAlignment(.center) + .padding(.vertical, 8) + .padding(.top, 4) } private var datePickerRow: some View { diff --git a/WordPress/Classes/ViewRelated/Post/Scheduling/PublishSettingsViewController.swift b/WordPress/Classes/ViewRelated/Post/Scheduling/PublishSettingsViewController.swift deleted file mode 100644 index 1a06c186717b..000000000000 --- a/WordPress/Classes/ViewRelated/Post/Scheduling/PublishSettingsViewController.swift +++ /dev/null @@ -1,47 +0,0 @@ -import Foundation -import WordPressData -import WordPressShared - -struct PublishSettingsViewModel { - enum State { - case scheduled(Date) - case published(Date) - case immediately - - init(post: AbstractPost) { - if let date = post.dateCreated { - self = date > .now ? .scheduled(date) : .published(date) - } else { - self = .immediately - } - } - } - - private(set) var state: State - let timeZone: TimeZone - - private let post: AbstractPost - - var isRequired: Bool { post.original().isStatus(in: [.publish, .scheduled]) } - - init(post: AbstractPost) { - state = State(post: post) - - self.post = post - timeZone = post.blog.timeZone ?? TimeZone.current - } - - var date: Date? { - switch state { - case .scheduled(let date), .published(let date): - return date - case .immediately: - return nil - } - } - - mutating func setDate(_ date: Date?) { - post.dateCreated = date - state = State(post: post) - } -} diff --git a/WordPress/Classes/ViewRelated/Post/Views/PostAuthorPicker.swift b/WordPress/Classes/ViewRelated/Post/Views/PostAuthorPicker.swift new file mode 100644 index 000000000000..905750659de4 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Post/Views/PostAuthorPicker.swift @@ -0,0 +1,92 @@ +import SwiftUI +import WordPressData +import WordPressUI +import WordPressShared + +struct PostAuthorPicker: View { + @StateObject private var viewModel: PostAuthorPickerViewModel + @State private var searchText = "" + @Environment(\.dismiss) private var dismiss + + init(blog: Blog, currentAuthorID: Int?, onSelection: @escaping (PostAuthorPickerViewModel.AuthorItem) -> Void) { + _viewModel = StateObject(wrappedValue: PostAuthorPickerViewModel(blog: blog, currentAuthorID: currentAuthorID, onSelection: onSelection)) + } + + /// Convenience initializer that extracts blog and authorID from post + init(post: AbstractPost, onSelection: @escaping (PostAuthorPickerViewModel.AuthorItem) -> Void) { + self.init(blog: post.blog, currentAuthorID: post.authorID?.intValue, onSelection: onSelection) + } + + var body: some View { + List { + ForEach(filteredAuthors) { author in + Button { + viewModel.selectAuthor(author) + dismiss() + } label: { + AuthorRow(author: author, isSelected: viewModel.isSelected(author)) + } + .buttonStyle(.plain) + } + } + .environment(\.defaultMinListRowHeight, 54) + .listStyle(.plain) + .searchable(text: $searchText) + .navigationTitle(Strings.title) + .navigationBarTitleDisplayMode(.inline) + } + + private var filteredAuthors: [PostAuthorPickerViewModel.AuthorItem] { + if searchText.isEmpty { + return viewModel.authors + } + return viewModel.authors.search(searchText) { author in + // Combine display name and username for search + var searchableText = author.displayName + if let username = author.username { + searchableText += " @\(username)" + } + return searchableText + } + } +} + +private struct AuthorRow: View { + let author: PostAuthorPickerViewModel.AuthorItem + let isSelected: Bool + + var body: some View { + HStack(spacing: 12) { + AvatarView(style: .single(author.avatarURL), diameter: 36) + + VStack(alignment: .leading) { + Text(author.displayName) + .font(.callout.weight(.medium)) + + if let username = author.username { + Text("@\(username)") + .font(.footnote) + .foregroundColor(.secondary) + } + } + .lineLimit(1) + + Spacer() + + if isSelected { + Image(systemName: "checkmark") + .foregroundColor(.accentColor) + .fontWeight(.medium) + } + } + .contentShape(Rectangle()) + } +} + +private enum Strings { + static let title = NSLocalizedString( + "postAuthorPicker.title", + value: "Author", + comment: "Title for the post author selection screen" + ) +} diff --git a/WordPress/Classes/ViewRelated/Post/Views/PostAuthorPickerViewModel.swift b/WordPress/Classes/ViewRelated/Post/Views/PostAuthorPickerViewModel.swift new file mode 100644 index 000000000000..9041a50cf32e --- /dev/null +++ b/WordPress/Classes/ViewRelated/Post/Views/PostAuthorPickerViewModel.swift @@ -0,0 +1,51 @@ +import Foundation +import WordPressData +import Combine + +@MainActor +final class PostAuthorPickerViewModel: ObservableObject { + struct AuthorItem: Identifiable { + let id: NSNumber + let displayName: String + let username: String? + let avatarURL: URL? + + init(from blogAuthor: BlogAuthor) { + self.id = blogAuthor.userID + self.displayName = blogAuthor.displayName ?? "" + self.username = blogAuthor.username + self.avatarURL = blogAuthor.avatarURL.flatMap { URL(string: $0) } + } + } + + @Published private(set) var authors: [AuthorItem] = [] + + private let blog: Blog + private let onSelection: (AuthorItem) -> Void + private let currentAuthorID: Int? + + init(blog: Blog, currentAuthorID: Int?, onSelection: @escaping (AuthorItem) -> Void) { + self.blog = blog + self.currentAuthorID = currentAuthorID + self.onSelection = onSelection + + loadAuthors() + } + + func selectAuthor(_ author: AuthorItem) { + onSelection(author) + } + + func isSelected(_ author: AuthorItem) -> Bool { + author.id.intValue == currentAuthorID + } + + private func loadAuthors() { + authors = (blog.authors ?? []) + .filter { !$0.deletedFromBlog } + .map(AuthorItem.init) + .sorted { + $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending + } + } +} diff --git a/WordPress/Resources/AppImages.xcassets/wpdl-category.imageset/Contents.json b/WordPress/Resources/AppImages.xcassets/wpdl-category.imageset/Contents.json new file mode 100644 index 000000000000..15594139723c --- /dev/null +++ b/WordPress/Resources/AppImages.xcassets/wpdl-category.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "category.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/WordPress/Resources/AppImages.xcassets/wpdl-category.imageset/category.svg b/WordPress/Resources/AppImages.xcassets/wpdl-category.imageset/category.svg new file mode 100644 index 000000000000..9805279009ea --- /dev/null +++ b/WordPress/Resources/AppImages.xcassets/wpdl-category.imageset/category.svg @@ -0,0 +1,3 @@ + + + diff --git a/WordPress/Resources/AppImages.xcassets/wpdl-tag.imageset/Contents.json b/WordPress/Resources/AppImages.xcassets/wpdl-tag.imageset/Contents.json new file mode 100644 index 000000000000..6bc0b27fc179 --- /dev/null +++ b/WordPress/Resources/AppImages.xcassets/wpdl-tag.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "tag.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/WordPress/Resources/AppImages.xcassets/wpdl-tag.imageset/tag.svg b/WordPress/Resources/AppImages.xcassets/wpdl-tag.imageset/tag.svg new file mode 100644 index 000000000000..ae1f657fc58c --- /dev/null +++ b/WordPress/Resources/AppImages.xcassets/wpdl-tag.imageset/tag.svg @@ -0,0 +1,3 @@ + + + diff --git a/WordPress/UITests/Tests/EditorAztecTests.swift b/WordPress/UITests/Tests/EditorAztecTests.swift index 7f756d5d48e7..50afd3c72232 100644 --- a/WordPress/UITests/Tests/EditorAztecTests.swift +++ b/WordPress/UITests/Tests/EditorAztecTests.swift @@ -48,7 +48,7 @@ class EditorAztecTests: XCTestCase { // .verifyPostSettings(withCategory: category, withTag: tag, hasImage: false) // .setFeaturedImage() // .verifyPostSettings(withCategory: category, withTag: tag, hasImage: true) -// .closePostSettings() +// .savePostSettings() // AztecEditorScreen(mode: .rich).publish() // .viewPublishedPost(withTitle: title) // .verifyEpilogueDisplays(postTitle: title, siteAddress: WPUITestCredentials.testWPcomPaidSite) diff --git a/WordPress/UITests/Tests/EditorGutenbergTests.swift b/WordPress/UITests/Tests/EditorGutenbergTests.swift index a02f20099a83..4f896dddd475 100644 --- a/WordPress/UITests/Tests/EditorGutenbergTests.swift +++ b/WordPress/UITests/Tests/EditorGutenbergTests.swift @@ -61,7 +61,7 @@ class EditorGutenbergTests_02: EditorGutenbergTests { .openPostSettings() .selectCategory(name: "Wedding") .addTag(name: "tag \(Date().toString())") - .closePostSettings() + .savePostSettings() .postAndViewEpilogue(action: .publish) .verifyEpilogueDisplays(postTitle: postTitle, siteAddress: WPUITestCredentials.testWPcomPaidSite) .tapDone() @@ -81,7 +81,7 @@ class EditorGutenbergTests_03: EditorGutenbergTests { .verifyPostSettings(hasImage: false) .setFeaturedImage() .verifyPostSettings(hasImage: true) - .closePostSettings() + .savePostSettings() } } diff --git a/WordPress/WordPress.xcodeproj/project.pbxproj b/WordPress/WordPress.xcodeproj/project.pbxproj index 275b22af2a9e..95994a875704 100644 --- a/WordPress/WordPress.xcodeproj/project.pbxproj +++ b/WordPress/WordPress.xcodeproj/project.pbxproj @@ -1127,8 +1127,6 @@ ViewRelated/Comments/Controllers/CommentsViewController.h, ViewRelated/Menus/Controllers/MenuItemsViewController.h, ViewRelated/Menus/Controllers/MenusViewController.h, - ViewRelated/Pages/PageSettingsViewController.h, - ViewRelated/Post/PostSettingsViewController.h, ViewRelated/Stats/StatsViewController.h, ViewRelated/Suggestions/SuggestionsTableView.h, ViewRelated/Suggestions/SuggestionsTableViewCell.h,