Skip to content

Commit 44b037c

Browse files
committed
Integrate ReaderPostHeaderView
1 parent 63d18c8 commit 44b037c

11 files changed

Lines changed: 497 additions & 137 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: 188 additions & 106 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: 42 additions & 19 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
@@ -466,7 +469,7 @@ class ReaderDetailCoordinator {
466469
WPAppAnalytics.track(.readerSitePreviewed, withProperties: properties)
467470
}
468471

469-
private func showTopic(_ topic: String) {
472+
func showTopic(_ topic: String) {
470473
let controller = ReaderStreamViewController.controllerWithTagSlug(topic)
471474
controller.trackingContext.source = ScreenTrackingSource(ScreenID.Reader.article, component: ElementID.Reader.tagChip)
472475
viewController?.navigationController?.pushViewController(controller, animated: true)
@@ -534,7 +537,7 @@ class ReaderDetailCoordinator {
534537
scrollToHashIfNeeded()
535538
}
536539

537-
private func followSite(completion: @escaping () -> Void) {
540+
private func toggleFollowSite(completion: @escaping () -> Void) {
538541
guard let post else {
539542
return
540543
}
@@ -691,28 +694,48 @@ 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+
toggleFollowSite { [weak self] in
706+
view.isShowingSubscribeLoadingIndicator = false
707+
self?.view?.updateHeader()
708+
}
709+
case .author:
710+
showAuthorProfile()
711+
case .featuredImage:
712+
showFeaturedImage(view.featuredImageView)
713+
}
706714
}
707715

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

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

718741
extension ReaderDetailCoordinator: ReaderDetailLikesViewDelegate {

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

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,12 @@
3939
<stackView opaque="NO" contentMode="scaleToFill" ambiguous="YES" axis="vertical" translatesAutoresizingMaskIntoConstraints="NO" id="ybg-ZD-3Ou">
4040
<rect key="frame" x="16" y="270.5" width="414" height="0.0"/>
4141
</stackView>
42+
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="fXc-TM-Dgr" userLabel="Tags Container View">
43+
<rect key="frame" x="16" y="270.5" width="414" height="0.0"/>
44+
<constraints>
45+
<constraint firstAttribute="height" placeholder="YES" id="tKj-Qz-4Np"/>
46+
</constraints>
47+
</view>
4248
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="qXQ-id-Ffz" userLabel="Likes Container View">
4349
<rect key="frame" x="16" y="270.5" width="414" height="0.0"/>
4450
<constraints>
@@ -108,7 +114,7 @@
108114
<constraint firstItem="iSu-TI-yew" firstAttribute="leading" secondItem="9JA-VQ-zzw" secondAttribute="leading" constant="16" placeholder="YES" id="9Vy-Wt-ZIb"/>
109115
<constraint firstItem="6yS-ZE-nbR" firstAttribute="top" secondItem="qXQ-id-Ffz" secondAttribute="bottom" id="DJi-VX-sTS"/>
110116
<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"/>
117+
<constraint firstItem="iSu-TI-yew" firstAttribute="top" secondItem="Xyq-y6-zPR" secondAttribute="bottom" id="IET-mv-Ieo"/>
112118
<constraint firstItem="Xyq-y6-zPR" firstAttribute="top" secondItem="9JA-VQ-zzw" secondAttribute="top" id="JZU-vN-GKO"/>
113119
<constraint firstItem="6yS-ZE-nbR" firstAttribute="width" secondItem="iSu-TI-yew" secondAttribute="width" id="LmZ-4g-gFE"/>
114120
<constraint firstItem="ybg-ZD-3Ou" firstAttribute="top" secondItem="iSu-TI-yew" secondAttribute="bottom" id="QH3-gd-a9s"/>
@@ -121,7 +127,10 @@
121127
<constraint firstItem="CpT-U7-bfv" firstAttribute="top" secondItem="6yS-ZE-nbR" secondAttribute="bottom" id="sQt-BP-vDY"/>
122128
<constraint firstItem="CpT-U7-bfv" firstAttribute="width" secondItem="iSu-TI-yew" secondAttribute="width" id="wUK-AO-ZOc"/>
123129
<constraint firstItem="Xyq-y6-zPR" firstAttribute="width" secondItem="iSu-TI-yew" secondAttribute="width" constant="32" id="xfj-7c-Lke"/>
124-
<constraint firstItem="ybg-ZD-3Ou" firstAttribute="bottom" secondItem="qXQ-id-Ffz" secondAttribute="top" id="yVj-JV-rBF"/>
130+
<constraint firstItem="ybg-ZD-3Ou" firstAttribute="bottom" secondItem="fXc-TM-Dgr" secondAttribute="top" id="yVj-JV-rBF"/>
131+
<constraint firstItem="fXc-TM-Dgr" firstAttribute="width" secondItem="iSu-TI-yew" secondAttribute="width" id="2Cr-Ax-aBc"/>
132+
<constraint firstItem="fXc-TM-Dgr" firstAttribute="centerX" secondItem="iSu-TI-yew" secondAttribute="centerX" id="3Ju-Qx-bCd"/>
133+
<constraint firstItem="fXc-TM-Dgr" firstAttribute="bottom" secondItem="qXQ-id-Ffz" secondAttribute="top" constant="-12" id="4Kl-Nx-eFg"/>
125134
</constraints>
126135
<viewLayoutGuide key="contentLayoutGuide" id="QF8-fp-xzq"/>
127136
<viewLayoutGuide key="frameLayoutGuide" id="eXr-4k-Adq"/>
@@ -149,6 +158,7 @@
149158
<outlet property="likesContainerView" destination="qXQ-id-Ffz" id="DL3-un-wtF"/>
150159
<outlet property="relatedPostsTableView" destination="CpT-U7-bfv" id="Ndh-H4-FlR"/>
151160
<outlet property="scrollView" destination="9JA-VQ-zzw" id="lCO-o1-bLB"/>
161+
<outlet property="tagsContainerView" destination="fXc-TM-Dgr" id="sQa-Ym-8pJ"/>
152162
<outlet property="webView" destination="iSu-TI-yew" id="DQy-Fd-C3y"/>
153163
<outlet property="webViewHeight" destination="ywz-kG-xyW" id="q3p-wI-yeb"/>
154164
</connections>

0 commit comments

Comments
 (0)