Skip to content

Commit c25e658

Browse files
committed
Integrate ReaderPostHeaderView
1 parent 63d18c8 commit c25e658

11 files changed

Lines changed: 534 additions & 91 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: 198 additions & 64 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: 73 additions & 15 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)
@@ -691,28 +694,72 @@ class ReaderDetailCoordinator {
691694
}
692695
}
693696

694-
// MARK: - ReaderDetailHeaderViewDelegate
695-
extension ReaderDetailCoordinator: ReaderDetailHeaderViewDelegate {
696-
func didTapBlogName() {
697-
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+
if view.isSubscribed {
705+
showUnsubscribeConfirmation(headerView: view)
706+
} else {
707+
view.isShowingSubscribeLoadingIndicator = true
708+
followSite { [weak self] in
709+
view.isShowingSubscribeLoadingIndicator = false
710+
self?.view?.updateHeader()
711+
}
712+
}
713+
case .author:
714+
showAuthorProfile()
715+
case .featuredImage:
716+
showFeaturedImage(view.featuredImageView)
717+
}
698718
}
699719

700-
func didTapTagButton() {
701-
showTag()
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)
702730
}
703731

704-
func didTapHeaderAvatar() {
705-
previewSite()
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)
706742
}
707743

708-
func didTapFollowButton(completion: @escaping () -> Void) {
709-
followSite(completion: completion)
710-
}
744+
private func showUnsubscribeConfirmation(headerView: ReaderPostHeaderView) {
745+
let alertController = UIAlertController(
746+
title: Strings.unsubscribeTitle,
747+
message: Strings.unsubscribeMessage,
748+
preferredStyle: .alert
749+
)
711750

712-
func didSelectTopic(_ topic: String) {
713-
showTopic(topic)
714-
}
751+
alertController.addAction(UIAlertAction(title: SharedStrings.Button.cancel, style: .cancel))
752+
alertController.addAction(UIAlertAction(title: Strings.unsubscribe, style: .destructive) { [weak self, weak headerView] _ in
753+
guard let self, let headerView else { return }
754+
headerView.isShowingSubscribeLoadingIndicator = true
755+
self.followSite { [weak headerView] in
756+
headerView?.isShowingSubscribeLoadingIndicator = false
757+
self.view?.updateHeader()
758+
}
759+
})
715760

761+
viewController?.present(alertController, animated: true)
762+
}
716763
}
717764

718765
extension ReaderDetailCoordinator: ReaderDetailLikesViewDelegate {
@@ -733,6 +780,17 @@ private extension ReaderDetailCoordinator {
733780
value: "You don't have permission to view this private blog.",
734781
comment: "Error message that informs reader detail from a private blog cannot be fetched."
735782
)
783+
static let unsubscribeTitle = NSLocalizedString(
784+
"reader.detail.unsubscribe.title",
785+
value: "Unsubscribe?",
786+
comment: "Title of the confirmation dialog when unsubscribing from a site"
787+
)
788+
static let unsubscribeMessage = NSLocalizedString(
789+
"reader.detail.unsubscribe.message",
790+
value: "Are you sure you want to unsubscribe from this site?",
791+
comment: "Message in the confirmation dialog when unsubscribing from a site"
792+
)
793+
static let unsubscribe = SharedStrings.Reader.unsubscribe
736794
}
737795

738796
}

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)