Reader: Integrate ReaderPostHeaderView#25465
Conversation
|
| App Name | WordPress | |
| Configuration | Release-Alpha | |
| Build Number | 32736 | |
| Version | PR #25465 | |
| Bundle ID | org.wordpress.alpha | |
| Commit | 42e2e01 | |
| Installation URL | 5qf23p2fnl3qg |
|
| App Name | Jetpack | |
| Configuration | Release-Alpha | |
| Build Number | 32736 | |
| Version | PR #25465 | |
| Bundle ID | com.jetpack.alpha | |
| Commit | 42e2e01 | |
| Installation URL | 3ocojjm07bc08 |
7c5243b to
467612e
Compare
| /// beginning of the content — if the summary is just a prefix of the content | ||
| /// (optionally ending with `[…]` or `…`), it's considered auto-generated | ||
| /// and `nil` is returned. | ||
| func getUserProvidedExcerpt() -> String? { |
There was a problem hiding this comment.
This is a temporary implementation. I'm working with the backend folks to add a "raw_excerpt" field to the response to eliminate this code.
| /// - text: The raw post content (may contain HTML/Markdown). | ||
| /// - wpm: Words per minute reading speed (default 238). | ||
| /// - Returns: Estimated reading time in minutes (minimum 1). | ||
| public static func compute(_ text: String, wpm: Double = 200) -> Int { |
There was a problem hiding this comment.
This is WIP and subject to future changes. We need to see how well it works in practice.
467612e to
0f34234
Compare
Generated by 🚫 Danger |
0f34234 to
c25e658
Compare
|
ee60136 to
cf707c1
Compare
jkmassel
left a comment
There was a problem hiding this comment.
This is close, but we should probably address the latin issue and the author URL issue
| totalSeconds += Double(max(12 - i, 3)) | ||
| } | ||
|
|
||
| // 5. Code block penalty (extra half-speed cost for code) |
There was a problem hiding this comment.
Where is the extra half-speed cost factored in?
There was a problem hiding this comment.
I believe this is where the "extra" comes from: totalSeconds at this point already counts whole text (clean), which includes the code blocks. This for-loop adds additional time to totalSeconds for reading code blocks.
| @Test func codeBlocksAddPenalty() { | ||
| let base = String(repeating: "word ", count: 200) | ||
| let withCode = base + "```let x = 1; let y = 2; let z = 3```" | ||
| #expect(ReaderReadTime.compute(withCode) >= ReaderReadTime.compute(base)) | ||
| } |
There was a problem hiding this comment.
This test should probably compare the same text with and without code fences delimiting code – otherwise you're just counting the extra words in withCode, I think?
| let result = ReaderReadTime.compute(post) | ||
| #expect(result == 12) | ||
| } | ||
| } |
There was a problem hiding this comment.
The following test would probably fail for posts with non-latin characters. It might be worth just detecting that we can't calculate a reading time for it and skipping that?
@Test func cjkTextProducesReasonableEstimate() {
// ~500 CJK characters ≈ roughly 2-3 min read
// CJK reading speed varies, but 500 characters should never be "1 min"
let text = String(repeating: "这是一个测试句子用来验证阅读时间", count: 35) // ~500 chars
#expect(ReaderReadTime.compute(text) > 1)
}| init(post: ReaderPost) { | ||
| self.avatarURL = post.avatarURLForDisplay() | ||
| self.name = post.authorForDisplay() ?? "" | ||
| self.siteURL = post.blogURL.flatMap(URL.init(string:)) |
There was a problem hiding this comment.
Above we use the author URL – should we do the same here? If someone is looking at a user profile, I'd imagine we'd want to show the site URL that the author has set, not the one for the site they're viewing?
|
Version |
cf707c1 to
0d2879f
Compare
Co-authored-by: Jeremy Massel <1123407+jkmassel@users.noreply.github.com>
The reading-time estimator counted words via a whitespace-based regex, which collapsed an unbroken run of Chinese, Japanese, or Thai text into a single word and pinned the estimate at the one-minute floor. Count characters from those scripts separately and charge them per character at a rate derived from the words-per-minute speed, leaving space-delimited languages (including Korean) unchanged.
| /// - text: The raw post content (may contain HTML/Markdown). | ||
| /// - wpm: Words per minute reading speed (default 200). | ||
| /// - Returns: Estimated reading time in minutes (minimum 1). | ||
| public static func compute(_ text: String, wpm: Double = 200) -> Int { |
There was a problem hiding this comment.
Is ViewModel.readingTime ever used by the header?
There was a problem hiding this comment.
Given that the "reading time label" was prototyped but deleted in this PR, my guess is that the computation code is left over. I have deleted it in b962966.
| result.append( | ||
| NSAttributedString(string: suffix, attributes: [ | ||
| .font: font.withWeight(.regular), | ||
| .foregroundColor: UIColor.label, |
There was a problem hiding this comment.
Will this UIColor.label use the right reader theme colours?
| delegate?.readerPostHeaderView(self, didTap: .featuredImage) | ||
| @objc private func excerptTapped() { | ||
| guard !isExcerptExpanded, let text = fullExcerptText else { return } | ||
| isExcerptExpanded = true |
There was a problem hiding this comment.
If the excerpt is expanded, then the reader theme is changed, does this update properly?
| buttonSubscribe.addTarget(self, action: #selector(subscribeTapped), for: .touchUpInside) | ||
|
|
||
| authorRow.isUserInteractionEnabled = true | ||
| authorRow.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(authorTapped))) |
There was a problem hiding this comment.
There might be a VoiceOver regression here – the old header gave the author/site row .isButton + a “Views posts from the site” hint and we should probably do the same?
| } | ||
|
|
||
| if post.isFollowing { | ||
| header.buttonSubscribe.isHidden = true |
There was a problem hiding this comment.
If I follow a blog, I can't use this button to unsubscribe. Repro steps:
- Follow a blog → post.isFollowing == true on its posts.
- Open a post → render() hides the header button.
- From the header: correct, you can’t unsubscribe — there’s no visible Subscribe/Subscribed control at all.
|
|
||
| // If the content starts with the excerpt, it's auto-generated | ||
| let plainContent = content.makePlainText() | ||
| if plainContent.hasPrefix(excerpt.prefix(50)) { |
There was a problem hiding this comment.
prefix(50) drops legitimate author excerpts that restate the opening sentence (and any excerpt <50 chars that prefixes the body). Worst case is a hidden excerpt (graceful), but there's no test coverage — consider a real signal or at least pin the boundaries with tests.
| case .siteName: | ||
| previewSite() | ||
| case .subscribe: | ||
| view.isShowingSubscribeLoadingIndicator = true |
|
|
||
| func didTapFollowButton(completion: @escaping () -> Void) { | ||
| followSite(completion: completion) | ||
| private func showFeaturedImage(_ sender: AsyncImageView) { |
There was a problem hiding this comment.
We should probably have WPAnalytics.trackReader(.readerArticleImageTapped) fired on this?
|
|
||
| // If the content starts with the excerpt, it's auto-generated | ||
| let plainContent = content.makePlainText() | ||
| if plainContent.hasPrefix(excerpt.prefix(50)) { |
There was a problem hiding this comment.
Concrete failing test for the prefix(50) problem above. It fails today — getUserProvidedExcerpt() returns nil and discards a real author excerpt whose first 50 chars match the body opening but then diverge.
Drop into a new Tests/KeystoneTests/Tests/Reader/ReaderPostExcerptTests.swift (XCTest + CoreDataTestCase, like the other Reader tests — it needs @testable import WordPress since the method is app-level):
import XCTest
@testable import WordPress
@testable import WordPressData
final class ReaderPostExcerptTests: CoreDataTestCase {
/// Author excerpt that opens with the post's first sentence, then diverges into
/// its own teaser. `getUserProvidedExcerpt()` only compares `excerpt.prefix(50)`
/// against the body, so it misclassifies this as auto-generated and drops it.
///
/// Expected: the author's excerpt is returned.
/// Actual (today): returns nil — this assertion fails.
func testKeepsAuthorExcerptThatSharesOpeningSentenceButDiverges() {
let excerpt = "We've been quietly rebuilding our sync engine for the past six months. Here's what it means for you."
let post = ReaderPostBuilder(mainContext).build()
post.content = "We've been quietly rebuilding our sync engine for the past six months. The new architecture replaces the legacy queue and halves battery use."
post.mt_excerpt = excerpt
XCTAssertEqual(post.getUserProvidedExcerpt(), excerpt)
}
}(Left as a code block rather than a one-click suggestion because the test file isn't part of this diff — paste it into the new file.)
There was a problem hiding this comment.
I think this is a not ideal but intentional solution. See this comment. The server side changes (see the linked Slack conversation in the comment) have not landed yet. Maybe we can improve this detection later?
|
|
||
| // If the content starts with the excerpt, it's auto-generated | ||
| let plainContent = content.makePlainText() | ||
| if plainContent.hasPrefix(excerpt.prefix(50)) { |
There was a problem hiding this comment.
We should probably compare against the full excerpt instead of an excerpt of the excerpt?
| if plainContent.hasPrefix(excerpt.prefix(50)) { | |
| if plainContent.hasPrefix(excerpt) { |
|
Version |
…e-new-header-view
The new ReaderPostHeaderView no longer displays reading time, so the estimation that fed it (the computation, the cached string, and the view model field) was dead code. Remove it end to end.
Hiding the button whenever the post was already followed dead-ended its Subscribed state and left no way to unsubscribe from the header. Let it stay visible so it renders Subscribed and toggles the follow state on tap.
The truncated excerpt's view more suffix used UIColor.label, which ignored the reader theme and turned invisible in dark themes, so it now uses the themed foreground color. The author row is also exposed as an accessibility button with a combined label and a hint, restoring the VoiceOver affordance the previous header provided.
Fire readerArticleImageTapped when the header's featured image opens the lightbox, matching the tracking already done for in-content images and galleries. Also fixes the indentation of the subscribe case.
|
@jkmassel Most of your comments have been addressed in the new commits. |







This PR is part of CMM-1999: Reader: Adaptive Post Details Page. It integrates new
ReaderPostHeaderViewon the "Post Details" screen. I also made some minor tweaks to the design based on the feedback (see the screenshot for the latest version).