Skip to content

Commit 467612e

Browse files
committed
Integrate ReaderPostHeaderView
1 parent 63d18c8 commit 467612e

11 files changed

Lines changed: 414 additions & 64 deletions

File tree

Modules/Sources/WordPressKitObjC/RemoteReaderPost.m

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,7 @@ - (instancetype)initWithDictionary:(NSDictionary *)dict;
134134
self.sortDate = [self sortDateFromPostDictionary:dict];
135135
self.sortRank = @(self.sortDate.timeIntervalSinceReferenceDate);
136136
self.status = [self stringOrEmptyString:[dict stringForKey:PostRESTKeyStatus]];
137+
self.excerpt = [self stringOrEmptyString:[dict stringForKey:PostRESTKeyExcerpt]];
137138
self.summary = [self postSummaryFromPostDictionary:dict orPostContent:self.content];
138139
self.tags = [self tagsFromPostDictionary:dict];
139140
self.isSharingEnabled = [[dict numberForKey:PostRESTKeySharingEnabled] boolValue];

Modules/Sources/WordPressKitObjC/include/RemoteReaderPost.h

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@ NS_ASSUME_NONNULL_BEGIN
3838
@property (nonatomic, strong, nullable) NSNumber *siteID;
3939
@property (nonatomic, strong, nullable) NSDate *sortDate;
4040
@property (nonatomic, strong, nullable) NSNumber *sortRank;
41+
/// - warning: It may still contain auto-generated excerpts, but they are not automatically trimmed like `summary`.
42+
@property (nonatomic, strong, nullable) NSString *excerpt;
4143
@property (nonatomic, strong, nullable) NSString *summary;
4244
@property (nonatomic, strong, nullable) NSString *tags;
4345
@property (nonatomic) BOOL isLikesEnabled;

Modules/Sources/WordPressReader/Detail/ReaderPostHeaderView.swift

Lines changed: 176 additions & 37 deletions
Large diffs are not rendered by default.
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import Foundation
2+
3+
public enum ReaderReadTime {
4+
/// Computes the estimated reading time in minutes from raw post content
5+
/// (HTML or Markdown), accounting for words, images, and code blocks.
6+
///
7+
/// - Parameters:
8+
/// - text: The raw post content (may contain HTML/Markdown).
9+
/// - wpm: Words per minute reading speed (default 238).
10+
/// - Returns: Estimated reading time in minutes (minimum 1).
11+
public static func compute(_ text: String, wpm: Double = 200) -> Int {
12+
// 1. Strip HTML & Markdown
13+
var clean = text
14+
clean = clean.replacing(#/<[^>]+>/#, with: "")
15+
clean = clean.replacing(#/!\[.*?\]\(.*?\)/#, with: "")
16+
clean = clean.replacing(#/\[.*?\]\(.*?\)/#, with: " ")
17+
18+
// 2. Count words
19+
let wordCount = clean.matches(of: #/\b\w+\b/#).count
20+
21+
// 3. Base reading time (seconds)
22+
var totalSeconds = (Double(wordCount) / wpm) * 60
23+
24+
// 4. Image penalty (12s → 3s floor, decreasing per image)
25+
let imageCount = text.matches(of: #/<img|!\[/#).count
26+
for i in 0..<imageCount {
27+
totalSeconds += Double(max(12 - i, 3))
28+
}
29+
30+
// 5. Code block penalty (extra half-speed cost for code)
31+
let codeMatches = text.matches(of: #/```[\s\S]*?```|`[^`]+`/#)
32+
for match in codeMatches {
33+
let codeWords = String(match.output).split(separator: " ").count
34+
totalSeconds += (Double(codeWords) / wpm) * 60
35+
}
36+
37+
return max(1, Int((totalSeconds / 60.0).rounded(.up)))
38+
}
39+
}
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import Testing
2+
@testable import WordPressReader
3+
4+
struct ReaderReadTimeTests {
5+
6+
@Test func shortText() {
7+
#expect(ReaderReadTime.compute("Hello world") == 1)
8+
}
9+
10+
@Test func plainText200Words() {
11+
// 200 words at 200 WPM = exactly 1 minute
12+
let text = String(repeating: "word ", count: 200)
13+
#expect(ReaderReadTime.compute(text) == 1)
14+
}
15+
16+
@Test func plainText500Words() {
17+
// 500 words / 200 WPM = 2.5 → rounds up to 3
18+
let text = String(repeating: "word ", count: 500)
19+
#expect(ReaderReadTime.compute(text) == 3)
20+
}
21+
22+
@Test func plainText1000Words() {
23+
// 1000 words / 200 WPM = 5 minutes
24+
let text = String(repeating: "word ", count: 1000)
25+
#expect(ReaderReadTime.compute(text) == 5)
26+
}
27+
28+
@Test func htmlTagsAreStripped() {
29+
let html = "<p>" + String(repeating: "word ", count: 500) + "</p>"
30+
let plain = String(repeating: "word ", count: 500)
31+
#expect(ReaderReadTime.compute(html) == ReaderReadTime.compute(plain))
32+
}
33+
34+
@Test func imagesAddPenalty() {
35+
// 200 words = 60s base. 3 images add 12 + 11 + 10 = 33s → 93s → 2 min
36+
let base = String(repeating: "word ", count: 200)
37+
let withImages = base + "<img src=\"a.png\"><img src=\"b.png\"><img src=\"c.png\">"
38+
#expect(ReaderReadTime.compute(base) == 1)
39+
#expect(ReaderReadTime.compute(withImages) == 2)
40+
}
41+
42+
@Test func codeBlocksAddPenalty() {
43+
let base = String(repeating: "word ", count: 200)
44+
let withCode = base + "```let x = 1; let y = 2; let z = 3```"
45+
#expect(ReaderReadTime.compute(withCode) >= ReaderReadTime.compute(base))
46+
}
47+
48+
@Test func longPost() {
49+
// ~2500 word blog post with HTML, images, and code
50+
var post = "<h1>Getting Started with Swift Concurrency</h1>"
51+
post += "<p>" + String(repeating: "This is a detailed explanation of the concept. ", count: 100) + "</p>"
52+
post += "<img src=\"diagram1.png\">"
53+
post += "<p>" + String(repeating: "Here we explore another important aspect of the topic. ", count: 100) + "</p>"
54+
post += "<img src=\"diagram2.png\">"
55+
post += "<pre><code>```func fetchData() async throws { let data = try await URLSession.shared.data(from: url)```</code></pre>"
56+
post += "<p>" + String(repeating: "In conclusion this wraps up the discussion nicely. ", count: 50) + "</p>"
57+
// ~2500 words / 200 WPM ≈ 12.5 min + image/code penalties → ~13 min
58+
let result = ReaderReadTime.compute(post)
59+
#expect(result == 12)
60+
}
61+
}

Sources/WordPressData/Mapping/ReaderPost+Mapping.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ extension ReaderPost {
7373
permaLink = remotePost.permalink
7474
postID = remotePost.postID
7575
postTitle = remotePost.postTitle
76+
mt_excerpt = remotePost.excerpt?.nonEmptyString()
7677
railcar = remotePost.railcar
7778
score = remotePost.score
7879
siteID = remotePost.siteID

WordPress/Classes/Models/ReaderPost+Swift.swift

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import Foundation
22
import WordPressData
3+
import WordPressReader
34
import WordPressUI
45
import SwiftSoup
56

@@ -63,6 +64,49 @@ extension ReaderPost {
6364
try? lookup(withID: postID, forSiteWithID: siteID, in: context)
6465
}
6566

67+
/// Returns estimated reading time in minutes.
68+
///
69+
/// Uses the API-provided `readingTime` when available, otherwise computes
70+
/// it from the post content accounting for words, images, and code blocks.
71+
func getEstimatedReadingTime() -> Int {
72+
if let minutes = readingTime?.intValue, minutes > 0 {
73+
return minutes
74+
}
75+
guard let content = contentForDisplay(), !content.isEmpty else {
76+
return 0
77+
}
78+
return ReaderReadTime.compute(content)
79+
}
80+
81+
/// Returns the excerpt only if it was explicitly provided by the post author.
82+
///
83+
/// The API always returns a `excerpt`, but it's usually auto-generated by
84+
/// truncating the post content. This method compares the summary against the
85+
/// beginning of the content — if the summary is just a prefix of the content
86+
/// (optionally ending with `[…]` or `…`), it's considered auto-generated
87+
/// and `nil` is returned.
88+
func getUserProvidedExcerpt() -> String? {
89+
guard let excerpt = mt_excerpt?.makePlainText(), !excerpt.isEmpty else {
90+
return nil
91+
}
92+
guard let content = contentForDisplay(), !content.isEmpty else {
93+
return excerpt
94+
}
95+
96+
// Auto-generated excerpts end with a truncation marker
97+
if excerpt.hasSuffix("[…]") || excerpt.hasSuffix("") {
98+
return nil
99+
}
100+
101+
// If the content starts with the excerpt, it's auto-generated
102+
let plainContent = content.makePlainText()
103+
if plainContent.hasPrefix(excerpt.prefix(50)) {
104+
return nil
105+
}
106+
107+
return excerpt
108+
}
109+
66110
func makeExceptHTML() -> String {
67111
"""
68112
<html>

WordPress/Classes/ViewRelated/Reader/Detail/ReaderDetailCoordinator.swift

Lines changed: 44 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1+
import AsyncImageKit
12
import Foundation
3+
import SafariServices
4+
import SwiftUI
25
import WordPressData
36
import WordPressReader
47
import WordPressShared
@@ -691,28 +694,52 @@ class ReaderDetailCoordinator {
691694
}
692695
}
693696

694-
// MARK: - ReaderDetailHeaderViewDelegate
695-
extension ReaderDetailCoordinator: ReaderDetailHeaderViewDelegate {
696-
func didTapBlogName() {
697-
previewSite()
698-
}
699-
700-
func didTapTagButton() {
701-
showTag()
702-
}
703-
704-
func didTapHeaderAvatar() {
705-
previewSite()
697+
// MARK: - ReaderPostHeaderViewDelegate
698+
extension ReaderDetailCoordinator: ReaderPostHeaderViewDelegate {
699+
func readerPostHeaderView(_ view: ReaderPostHeaderView, didTap element: ReaderPostHeaderView.Element) {
700+
switch element {
701+
case .siteName:
702+
previewSite()
703+
case .subscribe:
704+
view.isShowingSubscribeLoadingIndicator = true
705+
followSite { [weak self] in
706+
view.isShowingSubscribeLoadingIndicator = true
707+
self?.view?.updateHeader()
708+
}
709+
case .author:
710+
showAuthorProfile()
711+
case .featuredImage:
712+
showFeaturedImage(view.featuredImageView)
713+
case .viewOriginal:
714+
guard let post, let link = post.permaLink, let url = URL(string: link) else { return }
715+
let safariVC = SFSafariViewController(url: url)
716+
viewController?.present(safariVC, animated: true)
717+
}
706718
}
707719

708-
func didTapFollowButton(completion: @escaping () -> Void) {
709-
followSite(completion: completion)
720+
private func showFeaturedImage(_ sender: AsyncImageView) {
721+
guard let post, let imageURL = post.featuredImage.flatMap(URL.init) else {
722+
return
723+
}
724+
let lightboxVC = LightboxViewController(sourceURL: imageURL, host: MediaHost(post))
725+
MainActor.assumeIsolated {
726+
lightboxVC.thumbnail = sender.image
727+
}
728+
lightboxVC.configureZoomTransition(sourceView: sender)
729+
viewController?.present(lightboxVC, animated: true)
710730
}
711731

712-
func didSelectTopic(_ topic: String) {
713-
showTopic(topic)
732+
private func showAuthorProfile() {
733+
guard let post else { return }
734+
let viewModel = ReaderUserProfileViewModel(post: post)
735+
let profileVC = UIHostingController(rootView: ReaderUserProfileView(viewModel: viewModel))
736+
let navigationVC = UINavigationController(rootViewController: profileVC)
737+
profileVC.navigationItem.leftBarButtonItem = UIBarButtonItem(systemItem: .close, primaryAction: .init { [weak profileVC] _ in
738+
profileVC?.presentingViewController?.dismiss(animated: true)
739+
})
740+
navigationVC.sheetPresentationController?.detents = [.medium()]
741+
viewController?.present(navigationVC, animated: true)
714742
}
715-
716743
}
717744

718745
extension ReaderDetailCoordinator: ReaderDetailLikesViewDelegate {

WordPress/Classes/ViewRelated/Reader/Detail/ReaderDetailViewController.storyboard

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,7 @@
108108
<constraint firstItem="iSu-TI-yew" firstAttribute="leading" secondItem="9JA-VQ-zzw" secondAttribute="leading" constant="16" placeholder="YES" id="9Vy-Wt-ZIb"/>
109109
<constraint firstItem="6yS-ZE-nbR" firstAttribute="top" secondItem="qXQ-id-Ffz" secondAttribute="bottom" id="DJi-VX-sTS"/>
110110
<constraint firstAttribute="trailing" secondItem="iSu-TI-yew" secondAttribute="trailing" constant="16" placeholder="YES" id="FvD-7O-znG"/>
111-
<constraint firstItem="iSu-TI-yew" firstAttribute="top" secondItem="Xyq-y6-zPR" secondAttribute="bottom" constant="16" id="IET-mv-Ieo"/>
111+
<constraint firstItem="iSu-TI-yew" firstAttribute="top" secondItem="Xyq-y6-zPR" secondAttribute="bottom" id="IET-mv-Ieo"/>
112112
<constraint firstItem="Xyq-y6-zPR" firstAttribute="top" secondItem="9JA-VQ-zzw" secondAttribute="top" id="JZU-vN-GKO"/>
113113
<constraint firstItem="6yS-ZE-nbR" firstAttribute="width" secondItem="iSu-TI-yew" secondAttribute="width" id="LmZ-4g-gFE"/>
114114
<constraint firstItem="ybg-ZD-3Ou" firstAttribute="top" secondItem="iSu-TI-yew" secondAttribute="bottom" id="QH3-gd-a9s"/>

WordPress/Classes/ViewRelated/Reader/Detail/ReaderDetailViewController.swift

Lines changed: 39 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,9 @@ class ReaderDetailViewController: UIViewController, ReaderDetailView {
7171
private let activityIndicator = UIActivityIndicatorView(style: .medium)
7272

7373
/// The actual header
74-
private lazy var header = ReaderDetailHeaderHostingView()
74+
private lazy var header = ReaderPostHeaderView()
75+
private var cachedExcerpt: String?
76+
private var cachedReadingTime: String?
7577

7678
/// Bottom toolbar helper
7779
private lazy var toolbar = ReaderDetailToolbar()
@@ -251,7 +253,9 @@ class ReaderDetailViewController: UIViewController, ReaderDetailView {
251253

252254
toolbar.configure(for: post, in: self)
253255
updateToolbarItems()
254-
header.configure(for: post)
256+
cachedExcerpt = post.getUserProvidedExcerpt()
257+
cachedReadingTime = Self.readingTimeString(for: post)
258+
configureHeaderView(with: post)
255259
fetchLikes()
256260
fetchComments()
257261
checkTranslationAvailability()
@@ -401,7 +405,8 @@ class ReaderDetailViewController: UIViewController, ReaderDetailView {
401405
}
402406

403407
func updateHeader() {
404-
header.refreshFollowButton()
408+
guard let post else { return }
409+
header.subscribeButton.isHidden = post.isFollowing
405410
}
406411

407412
func updateLikesView(with viewModel: ReaderDetailLikesViewModel) {
@@ -490,7 +495,7 @@ class ReaderDetailViewController: UIViewController, ReaderDetailView {
490495
headerContainerView.backgroundColor = displaySetting.color.background
491496

492497
// Header view
493-
header.displaySetting = displaySetting
498+
header.apply(displaySetting)
494499
}
495500

496501
// Update Reader Post web view
@@ -549,14 +554,39 @@ class ReaderDetailViewController: UIViewController, ReaderDetailView {
549554
}
550555

551556
private func configureHeader() {
552-
header.displaySetting = displaySetting
557+
header.apply(displaySetting)
553558
header.delegate = coordinator
559+
header.translatesAutoresizingMaskIntoConstraints = false
554560
headerContainerView.addSubview(header)
555-
headerContainerView.translatesAutoresizingMaskIntoConstraints = false
556-
557561
headerContainerView.pinSubviewToAllEdges(header)
558562
}
559563

564+
private func configureHeaderView(with post: ReaderPost, customTitle: String? = nil) {
565+
let featuredImageURL: URL? = post.contentIncludesFeaturedImage() ? nil : post.featuredImageURLForDisplay()
566+
header.configure(with: ReaderPostHeaderView.ViewModel(
567+
siteName: post.blogNameForDisplay(),
568+
postTitle: customTitle ?? post.titleForDisplay(),
569+
authorName: post.authorForDisplay() ?? "",
570+
authorAvatarURL: post.avatarURLForDisplay(),
571+
dateString: post.dateForDisplay()?.toMediumString() ?? "",
572+
featuredImageURL: featuredImageURL,
573+
excerpt: cachedExcerpt,
574+
readingTime: cachedReadingTime ?? ""
575+
))
576+
header.subscribeButton.isHidden = post.isFollowing
577+
}
578+
579+
private static func readingTimeString(for post: ReaderPost) -> String {
580+
String.localizedStringWithFormat(
581+
NSLocalizedString(
582+
"reader.detail.header.readingTime",
583+
value: "%1$d min read",
584+
comment: "Estimated reading time for the post. %1$d is the number of minutes."
585+
),
586+
max(1, post.getEstimatedReadingTime())
587+
)
588+
}
589+
560590
private func fetchLikes() {
561591
guard let post else {
562592
return
@@ -687,7 +717,7 @@ class ReaderDetailViewController: UIViewController, ReaderDetailView {
687717
blurView.removeFromSuperview()
688718
}
689719

690-
header.configure(for: post, title: translationResults[0])
720+
configureHeaderView(with: post, customTitle: translationResults[0])
691721
do {
692722
try await webView.setBodyHTML(translationResults[1])
693723
} catch {
@@ -897,7 +927,7 @@ class ReaderDetailViewController: UIViewController, ReaderDetailView {
897927
let refreshed = notification.userInfo?[NSRefreshedObjectsKey] as? Set<NSManagedObject> ?? Set()
898928

899929
if updated.contains(post) || refreshed.contains(post) {
900-
header.configure(for: post)
930+
configureHeaderView(with: post)
901931
}
902932
}
903933
}

0 commit comments

Comments
 (0)