Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions RELEASE-NOTES.txt
Original file line number Diff line number Diff line change
@@ -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]
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
87 changes: 87 additions & 0 deletions WordPress/Classes/ViewRelated/Post/Views/PostAuthorPicker.swift
Original file line number Diff line number Diff line change
@@ -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))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I presume this is generated by AI? I feel bad about annoying you with the same comments. Any chance we can tell AI not to generate code like this?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe we should rethink its usage. It's a standard officially documented pattern and there are only a few scenarios where it can lead to issues. It's too convenient not to use because it's the only good way to inject observable state into a view and have it retain it (state/stateObject). In this case, it's not issue because it's a new screen every time and a new view identify, to it rests.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are multiple precedents in the codebase where StateObject(wrappedValue:) is used. I'd continue using it where it's OK – if it's part of the new screen it's fine.

}

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"
)
}
Original file line number Diff line number Diff line change
@@ -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
}
}
}