Skip to content
Open
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 Modules/Sources/WordPressKitObjC/RemoteReaderPost.m
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@ - (instancetype)initWithDictionary:(NSDictionary *)dict;
self.sortDate = [self sortDateFromPostDictionary:dict];
self.sortRank = @(self.sortDate.timeIntervalSinceReferenceDate);
self.status = [self stringOrEmptyString:[dict stringForKey:PostRESTKeyStatus]];
self.excerpt = [self stringOrEmptyString:[dict stringForKey:PostRESTKeyExcerpt]];
self.summary = [self postSummaryFromPostDictionary:dict orPostContent:self.content];
self.tags = [self tagsFromPostDictionary:dict];
self.isSharingEnabled = [[dict numberForKey:PostRESTKeySharingEnabled] boolValue];
Expand Down
2 changes: 2 additions & 0 deletions Modules/Sources/WordPressKitObjC/include/RemoteReaderPost.h
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ NS_ASSUME_NONNULL_BEGIN
@property (nonatomic, strong, nullable) NSNumber *siteID;
@property (nonatomic, strong, nullable) NSDate *sortDate;
@property (nonatomic, strong, nullable) NSNumber *sortRank;
/// - warning: It may still contain auto-generated excerpts, but they are not automatically trimmed like `summary`.
@property (nonatomic, strong, nullable) NSString *excerpt;
@property (nonatomic, strong, nullable) NSString *summary;
@property (nonatomic, strong, nullable) NSString *tags;
@property (nonatomic) BOOL isLikesEnabled;
Expand Down
262 changes: 198 additions & 64 deletions Modules/Sources/WordPressReader/Detail/ReaderPostHeaderView.swift

Large diffs are not rendered by default.

39 changes: 39 additions & 0 deletions Modules/Sources/WordPressReader/ReaderReadTime.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import Foundation

public enum ReaderReadTime {
/// Computes the estimated reading time in minutes from raw post content
/// (HTML or Markdown), accounting for words, images, and code blocks.
///
/// - Parameters:
/// - 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 {
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.

This is WIP and subject to future changes. We need to see how well it works in practice.

// 1. Strip HTML & Markdown
var clean = text
clean = clean.replacing(#/<[^>]+>/#, with: "")
clean = clean.replacing(#/!\[.*?\]\(.*?\)/#, with: "")
clean = clean.replacing(#/\[.*?\]\(.*?\)/#, with: " ")

// 2. Count words
let wordCount = clean.matches(of: #/\b\w+\b/#).count

// 3. Base reading time (seconds)
var totalSeconds = (Double(wordCount) / wpm) * 60

// 4. Image penalty (12s → 3s floor, decreasing per image)
let imageCount = text.matches(of: #/<img|!\[/#).count
for i in 0..<imageCount {
totalSeconds += Double(max(12 - i, 3))
}

// 5. Code block penalty (extra half-speed cost for code)
let codeMatches = text.matches(of: #/```[\s\S]*?```|`[^`]+`/#)
for match in codeMatches {
let codeWords = String(match.output).split(separator: " ").count
totalSeconds += (Double(codeWords) / wpm) * 60
}

return max(1, Int((totalSeconds / 60.0).rounded(.up)))
}
}
61 changes: 61 additions & 0 deletions Modules/Tests/WordPressReaderTests/ReaderReadTimeTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import Testing
@testable import WordPressReader

struct ReaderReadTimeTests {

@Test func shortText() {
#expect(ReaderReadTime.compute("Hello world") == 1)
}

@Test func plainText200Words() {
// 200 words at 200 WPM = exactly 1 minute
let text = String(repeating: "word ", count: 200)
#expect(ReaderReadTime.compute(text) == 1)
}

@Test func plainText500Words() {
// 500 words / 200 WPM = 2.5 → rounds up to 3
let text = String(repeating: "word ", count: 500)
#expect(ReaderReadTime.compute(text) == 3)
}

@Test func plainText1000Words() {
// 1000 words / 200 WPM = 5 minutes
let text = String(repeating: "word ", count: 1000)
#expect(ReaderReadTime.compute(text) == 5)
}

@Test func htmlTagsAreStripped() {
let html = "<p>" + String(repeating: "word ", count: 500) + "</p>"
let plain = String(repeating: "word ", count: 500)
#expect(ReaderReadTime.compute(html) == ReaderReadTime.compute(plain))
}

@Test func imagesAddPenalty() {
// 200 words = 60s base. 3 images add 12 + 11 + 10 = 33s → 93s → 2 min
let base = String(repeating: "word ", count: 200)
let withImages = base + "<img src=\"a.png\"><img src=\"b.png\"><img src=\"c.png\">"
#expect(ReaderReadTime.compute(base) == 1)
#expect(ReaderReadTime.compute(withImages) == 2)
}

@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))
}

@Test func longPost() {
// ~2500 word blog post with HTML, images, and code
var post = "<h1>Getting Started with Swift Concurrency</h1>"
post += "<p>" + String(repeating: "This is a detailed explanation of the concept. ", count: 100) + "</p>"
post += "<img src=\"diagram1.png\">"
post += "<p>" + String(repeating: "Here we explore another important aspect of the topic. ", count: 100) + "</p>"
post += "<img src=\"diagram2.png\">"
post += "<pre><code>```func fetchData() async throws { let data = try await URLSession.shared.data(from: url)```</code></pre>"
post += "<p>" + String(repeating: "In conclusion this wraps up the discussion nicely. ", count: 50) + "</p>"
// ~2500 words / 200 WPM ≈ 12.5 min + image/code penalties → ~13 min
let result = ReaderReadTime.compute(post)
#expect(result == 12)
}
}
1 change: 1 addition & 0 deletions Sources/WordPressData/Mapping/ReaderPost+Mapping.swift
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ extension ReaderPost {
permaLink = remotePost.permalink
postID = remotePost.postID
postTitle = remotePost.postTitle
mt_excerpt = remotePost.excerpt?.nonEmptyString()
railcar = remotePost.railcar
score = remotePost.score
siteID = remotePost.siteID
Expand Down
44 changes: 44 additions & 0 deletions WordPress/Classes/Models/ReaderPost+Swift.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import Foundation
import WordPressData
import WordPressReader
import WordPressUI
import SwiftSoup

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

/// Returns estimated reading time in minutes.
///
/// Uses the API-provided `readingTime` when available, otherwise computes
/// it from the post content accounting for words, images, and code blocks.
func getEstimatedReadingTime() -> Int {
if let minutes = readingTime?.intValue, minutes > 0 {
return minutes
}
guard let content = contentForDisplay(), !content.isEmpty else {
return 0
}
return ReaderReadTime.compute(content)
}

/// Returns the excerpt only if it was explicitly provided by the post author.
///
/// The API always returns a `excerpt`, but it's usually auto-generated by
/// truncating the post content. This method compares the summary against the
/// 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? {
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.

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.

guard let excerpt = mt_excerpt?.makePlainText(), !excerpt.isEmpty else {
return nil
}
guard let content = contentForDisplay(), !content.isEmpty else {
return excerpt
}

// Auto-generated excerpts end with a truncation marker
if excerpt.hasSuffix("[…]") || excerpt.hasSuffix("…") {
return nil
}

// If the content starts with the excerpt, it's auto-generated
let plainContent = content.makePlainText()
if plainContent.hasPrefix(excerpt.prefix(50)) {
return nil
}

return excerpt
}

func makeExceptHTML() -> String {
"""
<html>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import AsyncImageKit
import Foundation
import SafariServices
import SwiftUI
import WordPressData
import WordPressReader
import WordPressShared
Expand Down Expand Up @@ -466,7 +469,7 @@ class ReaderDetailCoordinator {
WPAppAnalytics.track(.readerSitePreviewed, withProperties: properties)
}

private func showTopic(_ topic: String) {
func showTopic(_ topic: String) {
let controller = ReaderStreamViewController.controllerWithTagSlug(topic)
controller.trackingContext.source = ScreenTrackingSource(ScreenID.Reader.article, component: ElementID.Reader.tagChip)
viewController?.navigationController?.pushViewController(controller, animated: true)
Expand Down Expand Up @@ -691,28 +694,72 @@ class ReaderDetailCoordinator {
}
}

// MARK: - ReaderDetailHeaderViewDelegate
extension ReaderDetailCoordinator: ReaderDetailHeaderViewDelegate {
func didTapBlogName() {
previewSite()
// MARK: - ReaderPostHeaderViewDelegate
extension ReaderDetailCoordinator: ReaderPostHeaderViewDelegate {
func readerPostHeaderView(_ view: ReaderPostHeaderView, didTap element: ReaderPostHeaderView.Element) {
switch element {
case .siteName:
previewSite()
case .subscribe:
if view.isSubscribed {
showUnsubscribeConfirmation(headerView: view)
} else {
view.isShowingSubscribeLoadingIndicator = true
followSite { [weak self] in
view.isShowingSubscribeLoadingIndicator = false
self?.view?.updateHeader()
}
}
case .author:
showAuthorProfile()
case .featuredImage:
showFeaturedImage(view.featuredImageView)
}
}

func didTapTagButton() {
showTag()
private func showFeaturedImage(_ sender: AsyncImageView) {
guard let post, let imageURL = post.featuredImage.flatMap(URL.init) else {
return
}
let lightboxVC = LightboxViewController(sourceURL: imageURL, host: MediaHost(post))
MainActor.assumeIsolated {
lightboxVC.thumbnail = sender.image
}
lightboxVC.configureZoomTransition(sourceView: sender)
viewController?.present(lightboxVC, animated: true)
}

func didTapHeaderAvatar() {
previewSite()
private func showAuthorProfile() {
guard let post else { return }
let viewModel = ReaderUserProfileViewModel(post: post)
let profileVC = UIHostingController(rootView: ReaderUserProfileView(viewModel: viewModel))
let navigationVC = UINavigationController(rootViewController: profileVC)
profileVC.navigationItem.leftBarButtonItem = UIBarButtonItem(systemItem: .close, primaryAction: .init { [weak profileVC] _ in
profileVC?.presentingViewController?.dismiss(animated: true)
})
navigationVC.sheetPresentationController?.detents = [.medium()]
viewController?.present(navigationVC, animated: true)
}

func didTapFollowButton(completion: @escaping () -> Void) {
followSite(completion: completion)
}
private func showUnsubscribeConfirmation(headerView: ReaderPostHeaderView) {
let alertController = UIAlertController(
title: Strings.unsubscribeTitle,
message: Strings.unsubscribeMessage,
preferredStyle: .alert
)

func didSelectTopic(_ topic: String) {
showTopic(topic)
}
alertController.addAction(UIAlertAction(title: SharedStrings.Button.cancel, style: .cancel))
alertController.addAction(UIAlertAction(title: Strings.unsubscribe, style: .destructive) { [weak self, weak headerView] _ in
guard let self, let headerView else { return }
headerView.isShowingSubscribeLoadingIndicator = true
self.followSite { [weak headerView] in
headerView?.isShowingSubscribeLoadingIndicator = false
self.view?.updateHeader()
}
})

viewController?.present(alertController, animated: true)
}
}

extension ReaderDetailCoordinator: ReaderDetailLikesViewDelegate {
Expand All @@ -733,6 +780,17 @@ private extension ReaderDetailCoordinator {
value: "You don't have permission to view this private blog.",
comment: "Error message that informs reader detail from a private blog cannot be fetched."
)
static let unsubscribeTitle = NSLocalizedString(
"reader.detail.unsubscribe.title",
value: "Unsubscribe?",
comment: "Title of the confirmation dialog when unsubscribing from a site"
)
static let unsubscribeMessage = NSLocalizedString(
"reader.detail.unsubscribe.message",
value: "Are you sure you want to unsubscribe from this site?",
comment: "Message in the confirmation dialog when unsubscribing from a site"
)
static let unsubscribe = SharedStrings.Reader.unsubscribe
}

}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,12 @@
<stackView opaque="NO" contentMode="scaleToFill" ambiguous="YES" axis="vertical" translatesAutoresizingMaskIntoConstraints="NO" id="ybg-ZD-3Ou">
<rect key="frame" x="16" y="270.5" width="414" height="0.0"/>
</stackView>
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="fXc-TM-Dgr" userLabel="Tags Container View">
<rect key="frame" x="16" y="270.5" width="414" height="0.0"/>
<constraints>
<constraint firstAttribute="height" placeholder="YES" id="tKj-Qz-4Np"/>
</constraints>
</view>
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="qXQ-id-Ffz" userLabel="Likes Container View">
<rect key="frame" x="16" y="270.5" width="414" height="0.0"/>
<constraints>
Expand Down Expand Up @@ -108,7 +114,7 @@
<constraint firstItem="iSu-TI-yew" firstAttribute="leading" secondItem="9JA-VQ-zzw" secondAttribute="leading" constant="16" placeholder="YES" id="9Vy-Wt-ZIb"/>
<constraint firstItem="6yS-ZE-nbR" firstAttribute="top" secondItem="qXQ-id-Ffz" secondAttribute="bottom" id="DJi-VX-sTS"/>
<constraint firstAttribute="trailing" secondItem="iSu-TI-yew" secondAttribute="trailing" constant="16" placeholder="YES" id="FvD-7O-znG"/>
<constraint firstItem="iSu-TI-yew" firstAttribute="top" secondItem="Xyq-y6-zPR" secondAttribute="bottom" constant="16" id="IET-mv-Ieo"/>
<constraint firstItem="iSu-TI-yew" firstAttribute="top" secondItem="Xyq-y6-zPR" secondAttribute="bottom" id="IET-mv-Ieo"/>
<constraint firstItem="Xyq-y6-zPR" firstAttribute="top" secondItem="9JA-VQ-zzw" secondAttribute="top" id="JZU-vN-GKO"/>
<constraint firstItem="6yS-ZE-nbR" firstAttribute="width" secondItem="iSu-TI-yew" secondAttribute="width" id="LmZ-4g-gFE"/>
<constraint firstItem="ybg-ZD-3Ou" firstAttribute="top" secondItem="iSu-TI-yew" secondAttribute="bottom" id="QH3-gd-a9s"/>
Expand All @@ -121,7 +127,10 @@
<constraint firstItem="CpT-U7-bfv" firstAttribute="top" secondItem="6yS-ZE-nbR" secondAttribute="bottom" id="sQt-BP-vDY"/>
<constraint firstItem="CpT-U7-bfv" firstAttribute="width" secondItem="iSu-TI-yew" secondAttribute="width" id="wUK-AO-ZOc"/>
<constraint firstItem="Xyq-y6-zPR" firstAttribute="width" secondItem="iSu-TI-yew" secondAttribute="width" constant="32" id="xfj-7c-Lke"/>
<constraint firstItem="ybg-ZD-3Ou" firstAttribute="bottom" secondItem="qXQ-id-Ffz" secondAttribute="top" id="yVj-JV-rBF"/>
<constraint firstItem="ybg-ZD-3Ou" firstAttribute="bottom" secondItem="fXc-TM-Dgr" secondAttribute="top" id="yVj-JV-rBF"/>
<constraint firstItem="fXc-TM-Dgr" firstAttribute="width" secondItem="iSu-TI-yew" secondAttribute="width" id="2Cr-Ax-aBc"/>
<constraint firstItem="fXc-TM-Dgr" firstAttribute="centerX" secondItem="iSu-TI-yew" secondAttribute="centerX" id="3Ju-Qx-bCd"/>
<constraint firstItem="fXc-TM-Dgr" firstAttribute="bottom" secondItem="qXQ-id-Ffz" secondAttribute="top" constant="-12" id="4Kl-Nx-eFg"/>
</constraints>
<viewLayoutGuide key="contentLayoutGuide" id="QF8-fp-xzq"/>
<viewLayoutGuide key="frameLayoutGuide" id="eXr-4k-Adq"/>
Expand Down Expand Up @@ -149,6 +158,7 @@
<outlet property="likesContainerView" destination="qXQ-id-Ffz" id="DL3-un-wtF"/>
<outlet property="relatedPostsTableView" destination="CpT-U7-bfv" id="Ndh-H4-FlR"/>
<outlet property="scrollView" destination="9JA-VQ-zzw" id="lCO-o1-bLB"/>
<outlet property="tagsContainerView" destination="fXc-TM-Dgr" id="sQa-Ym-8pJ"/>
<outlet property="webView" destination="iSu-TI-yew" id="DQy-Fd-C3y"/>
<outlet property="webViewHeight" destination="ywz-kG-xyW" id="q3p-wI-yeb"/>
</connections>
Expand Down
Loading