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/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/Blog.h b/Sources/WordPressData/Objective-C/include/Blog.h index cd8e03fd587c..b7e8e1b98c00 100644 --- a/Sources/WordPressData/Objective-C/include/Blog.h +++ b/Sources/WordPressData/Objective-C/include/Blog.h @@ -202,10 +202,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/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/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/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/PostSettingsViewController+Swift.swift b/WordPress/Classes/ViewRelated/Post/PostSettingsViewController+Swift.swift index 762707c6b9f8..8bd0a7581beb 100644 --- a/WordPress/Classes/ViewRelated/Post/PostSettingsViewController+Swift.swift +++ b/WordPress/Classes/ViewRelated/Post/PostSettingsViewController+Swift.swift @@ -302,6 +302,28 @@ extension PostSettingsViewController { } } +// MARK: - PostSettingsViewController (Post Format) + +extension PostSettingsViewController { + @objc public func showPostFormatSelector() { + guard let post = apost as? Post else { + return wpAssertionFailure("expected post type") + } + let pickerView = PostFormatPicker(post: post) { [weak self] format in + guard let self else { return } + if post.postFormatText() != format { + WPAnalytics.track(.editorPostFormatChanged, properties: ["via": "settings"]) + post.setPostFormatText(format) + } + self.navigationController?.popViewController(animated: true) + self.tableView.reloadData() + } + let pickerVC = UIHostingController(rootView: pickerView) + pickerVC.title = PostFormatPicker.title + navigationController?.pushViewController(pickerVC, 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.m b/WordPress/Classes/ViewRelated/Post/PostSettingsViewController.m index d05a01fda26c..a60b257bb026 100644 --- a/WordPress/Classes/ViewRelated/Post/PostSettingsViewController.m +++ b/WordPress/Classes/ViewRelated/Post/PostSettingsViewController.m @@ -46,7 +46,6 @@ @interface PostSettingsViewController () *unsupportedConnections; @@ -105,7 +104,6 @@ - (void)viewDidLoad [WPStyleGuide configureColorsForView:self.view andTableView:self.tableView]; [WPStyleGuide configureAutomaticHeightRowsFor:self.tableView]; - [self setupFormatsList]; [self setupPublicizeConnections]; [self.tableView registerClass:[UITableViewCell class] forCellReuseIdentifier:TableViewFeaturedImageCellIdentifier]; @@ -161,11 +159,6 @@ - (void)didReceiveMemoryWarning #pragma mark - Additional setup -- (void)setupFormatsList -{ - self.formatsList = self.post.blog.sortedPostFormatNames; -} - - (void)setupPublicizeConnections { // Separate Twitter connections if the service is unsupported. @@ -227,29 +220,7 @@ - (void)internetIsReachableAgain - (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(); - }]; + // Post format syncing is now handled in the SwiftUI PostFormatPicker with pull-to-refresh } // sync the latest state of Twitter. @@ -770,73 +741,7 @@ - (WPTableViewCell *)getWPTableViewImageAndAccessoryCell 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]; -} +// showPostFormatSelector is now implemented in PostSettingsViewController+Swift.swift - (void)toggleShareConnectionForIndexPath:(NSIndexPath *) indexPath { diff --git a/WordPress/Classes/ViewRelated/Post/Prepublishing/PrepublishingViewController+JetpackSocial.swift b/WordPress/Classes/ViewRelated/Post/Prepublishing/PrepublishingViewController+JetpackSocial.swift index a05a8f7b1954..0cb054bea797 100644 --- a/WordPress/Classes/ViewRelated/Post/Prepublishing/PrepublishingViewController+JetpackSocial.swift +++ b/WordPress/Classes/ViewRelated/Post/Prepublishing/PrepublishingViewController+JetpackSocial.swift @@ -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]]()