diff --git a/RELEASE-NOTES.txt b/RELEASE-NOTES.txt index aa4daec95f18..0447b656f70d 100644 --- a/RELEASE-NOTES.txt +++ b/RELEASE-NOTES.txt @@ -1,6 +1,7 @@ 26.0 ----- * [**] Add new “Subscribers” screen that shows both your email and Reader subscribers [#24513] +* [*] Add new "Author Picker" for "Post Settings" with search and better design [#24621] * [*] Improve search in TimeZone Picker for more accurate results [#24612] * [*] 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] 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/PostSettingsViewController+Swift.swift b/WordPress/Classes/ViewRelated/Post/PostSettingsViewController+Swift.swift index 14e7619f0edc..ead4f534f9da 100644 --- a/WordPress/Classes/ViewRelated/Post/PostSettingsViewController+Swift.swift +++ b/WordPress/Classes/ViewRelated/Post/PostSettingsViewController+Swift.swift @@ -286,13 +286,12 @@ extension PostSettingsViewController { } @objc public func showPostAuthorSelector() { - let authorVC = PostAuthorSelectorViewController(post: apost) - authorVC.completion = { [weak authorVC] in + let picker = PostAuthorPicker(post: apost) { [weak self] in WPAnalytics.track(.editorPostAuthorChanged, properties: ["via": "settings"]) - authorVC?.dismiss() // It pops VC - self.tableView.reloadData() + self?.tableView.reloadData() } - navigationController?.pushViewController(authorVC, animated: true) + let hostingController = UIHostingController(rootView: picker) + navigationController?.pushViewController(hostingController, animated: true) } @objc public func showTagsPicker() { diff --git a/WordPress/Classes/ViewRelated/Post/Views/PostAuthorPicker.swift b/WordPress/Classes/ViewRelated/Post/Views/PostAuthorPicker.swift new file mode 100644 index 000000000000..c4956967dea4 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Post/Views/PostAuthorPicker.swift @@ -0,0 +1,87 @@ +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(post: AbstractPost, onSelection: @escaping () -> Void) { + _viewModel = StateObject(wrappedValue: PostAuthorPickerViewModel(post: post, 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..83593b91f632 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Post/Views/PostAuthorPickerViewModel.swift @@ -0,0 +1,57 @@ +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 post: AbstractPost + private let onSelection: () -> Void + private let currentAuthorID: NSNumber? + + init(post: AbstractPost, onSelection: @escaping () -> Void) { + self.post = post + self.onSelection = onSelection + self.currentAuthorID = post.authorID + + loadAuthors() + } + + func selectAuthor(_ author: AuthorItem) { + guard !post.isFault, post.managedObjectContext != nil else { return } + + post.authorID = author.id + post.author = author.displayName + post.authorAvatarURL = author.avatarURL?.absoluteString + + onSelection() + } + + func isSelected(_ author: AuthorItem) -> Bool { + author.id == currentAuthorID + } + + private func loadAuthors() { + authors = (post.blog.authors ?? []) + .filter { !$0.deletedFromBlog } + .map(AuthorItem.init) + .sorted { + $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending + } + } +}