From 0d2879fe02116f795daefed1f77a61d07279605f Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Mon, 30 Mar 2026 15:33:34 -0400 Subject: [PATCH 1/8] Integrate ReaderPostHeaderView --- .../WordPressKitObjC/RemoteReaderPost.m | 1 + .../include/RemoteReaderPost.h | 2 + .../Detail/ReaderPostHeaderView.swift | 297 +++++++++++------- .../WordPressReader/ReaderReadTime.swift | 39 +++ .../ReaderReadTimeTests.swift | 61 ++++ .../Mapping/ReaderPost+Mapping.swift | 1 + .../Classes/Models/ReaderPost+Swift.swift | 44 +++ .../Detail/ReaderDetailCoordinator.swift | 60 ++-- .../ReaderDetailViewController.storyboard | 14 +- .../Detail/ReaderDetailViewController.swift | 111 ++++++- .../Reader/User/ReaderUserProfileView.swift | 6 + 11 files changed, 497 insertions(+), 139 deletions(-) create mode 100644 Modules/Sources/WordPressReader/ReaderReadTime.swift create mode 100644 Modules/Tests/WordPressReaderTests/ReaderReadTimeTests.swift diff --git a/Modules/Sources/WordPressKitObjC/RemoteReaderPost.m b/Modules/Sources/WordPressKitObjC/RemoteReaderPost.m index 2f7b0557d1c6..4e192dc4a2b0 100644 --- a/Modules/Sources/WordPressKitObjC/RemoteReaderPost.m +++ b/Modules/Sources/WordPressKitObjC/RemoteReaderPost.m @@ -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]; diff --git a/Modules/Sources/WordPressKitObjC/include/RemoteReaderPost.h b/Modules/Sources/WordPressKitObjC/include/RemoteReaderPost.h index 83a279b260e5..0e7d91d2010a 100644 --- a/Modules/Sources/WordPressKitObjC/include/RemoteReaderPost.h +++ b/Modules/Sources/WordPressKitObjC/include/RemoteReaderPost.h @@ -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; diff --git a/Modules/Sources/WordPressReader/Detail/ReaderPostHeaderView.swift b/Modules/Sources/WordPressReader/Detail/ReaderPostHeaderView.swift index 7a6fcfede0e8..d99b61118c4e 100644 --- a/Modules/Sources/WordPressReader/Detail/ReaderPostHeaderView.swift +++ b/Modules/Sources/WordPressReader/Detail/ReaderPostHeaderView.swift @@ -1,5 +1,6 @@ import UIKit import AsyncImageKit +import DesignSystem import WordPressShared import WordPressUI @@ -16,7 +17,6 @@ public final class ReaderPostHeaderView: UIView { case subscribe case author case featuredImage - case viewOriginal } public weak var delegate: ReaderPostHeaderViewDelegate? @@ -28,7 +28,7 @@ public final class ReaderPostHeaderView: UIView { public let postTitle: String public let authorName: String public let authorAvatarURL: URL? - public let dateString: String + public let dateString: String? public let featuredImageURL: URL? public let excerpt: String? public let readingTime: String @@ -38,7 +38,7 @@ public final class ReaderPostHeaderView: UIView { postTitle: String, authorName: String, authorAvatarURL: URL? = nil, - dateString: String, + dateString: String?, featuredImageURL: URL? = nil, excerpt: String? = nil, readingTime: String @@ -65,11 +65,10 @@ public final class ReaderPostHeaderView: UIView { return label }() - public let subscribeButton: UIButton = { + public let buttonSubscribe: UIButton = { var config = UIButton.Configuration.plain() - config.contentInsets = NSDirectionalEdgeInsets(top: 4, leading: 12, bottom: 4, trailing: 12) - config.cornerStyle = .capsule - config.background.strokeWidth = 1 + config.imagePadding = 8 // This sets padding for the built-in loading indicator + let button = UIButton(configuration: config) button.maximumContentSizeCategory = .extraExtraExtraLarge button.setContentHuggingPriority(.required, for: .horizontal) @@ -77,17 +76,6 @@ public final class ReaderPostHeaderView: UIView { return button }() - public let viewOriginalButton: UIButton = { - var config = UIButton.Configuration.plain() - config.contentInsets = .zero - config.imagePadding = 4 - config.imagePlacement = .leading - let button = UIButton(configuration: config) - button.maximumContentSizeCategory = .extraExtraLarge - button.setContentHuggingPriority(.required, for: .horizontal) - return button - }() - private let titleLabel: UITextView = { let textView = UITextView() textView.isEditable = false @@ -138,35 +126,30 @@ public final class ReaderPostHeaderView: UIView { let label = UILabel() label.numberOfLines = 0 label.adjustsFontForContentSizeCategory = true + label.isUserInteractionEnabled = true label.isHidden = true return label }() - private let readingTimeLabel: UILabel = { - let label = UILabel() - label.adjustsFontForContentSizeCategory = true - label.maximumContentSizeCategory = .extraExtraLarge - return label - }() - - private let readingTimeIcon: UIImageView = { - let imageView = UIImageView() - imageView.maximumContentSizeCategory = .extraExtraLarge - imageView.setContentHuggingPriority(.required, for: .horizontal) - imageView.setContentCompressionResistancePriority(.required, for: .horizontal) - return imageView - }() - private let separator = SeparatorView.horizontal(height: 1) // Stacks - private lazy var siteNameRow: UIStackView = { - let stack = UIStackView(arrangedSubviews: [siteNameLabel, subscribeButton, UIView()]) - stack.axis = .horizontal - stack.alignment = .firstBaseline - stack.spacing = 8 - return stack + private lazy var headerRow: UIView = { + let containerView = UIView() + containerView.addSubview(siteNameLabel) + containerView.addSubview(buttonSubscribe) + + siteNameLabel.pinEdges([.leading, .vertical]) + siteNameLabel.trailingAnchor.constraint(equalTo: buttonSubscribe.leadingAnchor, constant: -8).isActive = true + + buttonSubscribe.pinEdges(.trailing) + buttonSubscribe.centerYAnchor.constraint(equalTo: siteNameLabel.centerYAnchor).isActive = true + + // Site name shrinks first + buttonSubscribe.setContentCompressionResistancePriority(.init(999), for: .horizontal) + + return containerView }() private lazy var authorTextStack: UIStackView = { @@ -184,34 +167,16 @@ public final class ReaderPostHeaderView: UIView { return stack }() - private lazy var readingTimeStack: UIStackView = { - let stack = UIStackView(arrangedSubviews: [readingTimeIcon, readingTimeLabel]) - stack.axis = .horizontal - stack.spacing = 4 - stack.alignment = .center - return stack - }() - - private lazy var footerRow: UIStackView = { - let spacer = UIView() - spacer.setContentHuggingPriority(.defaultLow, for: .horizontal) - let stack = UIStackView(arrangedSubviews: [readingTimeStack, spacer, viewOriginalButton]) - stack.axis = .horizontal - stack.alignment = .center - stack.spacing = 8 - return stack - }() - private lazy var mainStack: UIStackView = { let stack = UIStackView(arrangedSubviews: [ - siteNameRow, + headerRow, titleLabel, authorRow, featuredImageView, excerptLabel, separator, - footerRow ]) + stack.setCustomSpacing(9, after: separator) stack.axis = .vertical stack.spacing = 12 return stack @@ -219,7 +184,25 @@ public final class ReaderPostHeaderView: UIView { private var featuredImageAspectConstraint: NSLayoutConstraint? private var avatarSizeConstraints: [NSLayoutConstraint] = [] - private var displaySetting: ReaderDisplaySettings = .standard + private var displaySettings: ReaderDisplaySettings = .standard + private var fullExcerptText: String? + private var isExcerptExpanded = false + private var lastExcerptLayoutWidth: CGFloat = 0 + + public var isSubscribed: Bool = false { + didSet { + guard isSubscribed != oldValue else { return } + updateSubscribeButtonAppearance() + } + } + + public var isShowingSubscribeLoadingIndicator: Bool = false { + didSet { + guard isShowingSubscribeLoadingIndicator != oldValue else { return } + buttonSubscribe.isEnabled = !isShowingSubscribeLoadingIndicator + buttonSubscribe.configuration?.showsActivityIndicator = isShowingSubscribeLoadingIndicator + } + } // MARK: - Init @@ -239,6 +222,7 @@ public final class ReaderPostHeaderView: UIView { titleLabel.text = viewModel.postTitle authorNameLabel.text = viewModel.authorName dateLabel.text = viewModel.dateString + dateLabel.isHidden = viewModel.dateString == nil if let avatarURL = viewModel.authorAvatarURL { avatarImageView.wp.setImage(with: avatarURL) @@ -246,68 +230,52 @@ public final class ReaderPostHeaderView: UIView { avatarImageView.image = nil } + mainStack.setCustomSpacing(viewModel.featuredImageURL != nil ? 18 : 12, after: authorRow) + configureFeaturedImage(with: viewModel.featuredImageURL) configureExcerpt(with: viewModel.excerpt) - configureReadingTime(with: viewModel.readingTime) } - public func apply(_ displaySetting: ReaderDisplaySettings) { - self.displaySetting = displaySetting + public func apply(_ displaySettings: ReaderDisplaySettings) { + self.displaySettings = displaySettings - let colors = displaySetting.color + let colors = displaySettings.color - siteNameLabel.font = displaySetting.font(with: .subheadline) + siteNameLabel.font = displaySettings.font(with: .subheadline) siteNameLabel.textColor = colors.secondaryForeground - titleLabel.font = displaySetting.font(with: .title1, weight: .bold) + titleLabel.font = displaySettings.font(with: .title1, weight: .bold) titleLabel.textColor = colors.foreground titleLabel.tintColor = colors.foreground avatarImageView.layer.borderColor = colors.foreground.withAlphaComponent(0.1).cgColor - authorNameLabel.font = displaySetting.font(with: .footnote, weight: .semibold) + authorNameLabel.font = displaySettings.font(with: .footnote, weight: .semibold) authorNameLabel.textColor = colors.foreground - dateLabel.font = displaySetting.font(with: .footnote) + dateLabel.font = displaySettings.font(with: .footnote) dateLabel.textColor = colors.secondaryForeground - excerptLabel.font = displaySetting.font(with: .callout) + excerptLabel.font = displaySettings.font(with: .callout) excerptLabel.textColor = colors.secondaryForeground - readingTimeLabel.font = displaySetting.font(with: .footnote) - readingTimeLabel.textColor = colors.secondaryForeground - - let iconConfig = UIImage.SymbolConfiguration(font: displaySetting.font(with: .caption1)) - readingTimeIcon.image = UIImage(systemName: "clock", withConfiguration: iconConfig) - readingTimeIcon.tintColor = colors.secondaryForeground - - let subscribeFont = displaySetting.font(with: .footnote, weight: .medium) - subscribeButton.configuration?.attributedTitle = AttributedString( - Strings.subscribe, - attributes: AttributeContainer([.font: subscribeFont]) - ) - subscribeButton.configuration?.baseForegroundColor = colors.secondaryForeground - subscribeButton.configuration?.background.strokeColor = colors.secondaryForeground.withAlphaComponent(0.3) - - viewOriginalButton.configuration?.attributedTitle = AttributedString( - Strings.viewOriginal, - attributes: AttributeContainer([.font: displaySetting.font(with: .footnote)]) - ) - viewOriginalButton.configuration?.image = UIImage(systemName: "arrow.up.right.circle", withConfiguration: UIImage.SymbolConfiguration(font: displaySetting.font(with: .caption2))) - viewOriginalButton.configuration?.baseForegroundColor = colors.secondaryForeground + buttonSubscribe.configuration?.baseForegroundColor = colors.secondaryForeground + updateSubscribeButtonAppearance() separator.backgroundColor = colors.border + + lastExcerptLayoutWidth = 0 + updateExcerptTruncation() } // MARK: - Private private func setupView() { addSubview(mainStack) - mainStack.pinEdges(insets: UIEdgeInsets(top: Constants.padding, left: Constants.padding, bottom: Constants.padding, right: Constants.padding)) + mainStack.pinEdges(insets: UIEdgeInsets(top: Constants.padding, left: Constants.padding, bottom: 20, right: Constants.padding)) - mainStack.setCustomSpacing(9, after: siteNameRow) - mainStack.setCustomSpacing(18, after: authorRow) - mainStack.setCustomSpacing(18, after: featuredImageView) + mainStack.setCustomSpacing(9, after: headerRow) + mainStack.setCustomSpacing(12, after: featuredImageView) avatarSizeConstraints = [ avatarImageView.widthAnchor.constraint(equalToConstant: Constants.avatarSize), @@ -322,19 +290,41 @@ public final class ReaderPostHeaderView: UIView { siteNameLabel.isUserInteractionEnabled = true siteNameLabel.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(siteNameTapped))) - subscribeButton.addTarget(self, action: #selector(subscribeTapped), for: .touchUpInside) + buttonSubscribe.addTarget(self, action: #selector(subscribeTapped), for: .touchUpInside) authorRow.isUserInteractionEnabled = true authorRow.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(authorTapped))) + excerptLabel.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(excerptTapped))) + featuredImageView.isUserInteractionEnabled = true featuredImageView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(featuredImageTapped))) - viewOriginalButton.addTarget(self, action: #selector(viewOriginalTapped), for: .touchUpInside) - apply(.standard) } + public override func layoutSubviews() { + super.layoutSubviews() + + let width = mainStack.bounds.width + if width > 0 && width != lastExcerptLayoutWidth { + lastExcerptLayoutWidth = width + updateExcerptTruncation() + } + } + + // Extends tap area of the controls. + public override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + let expandedViews: [UIView] = [buttonSubscribe, siteNameLabel, authorRow, featuredImageView] + for view in expandedViews where !view.isHidden { + let converted = convert(point, to: view) + if view.bounds.insetBy(dx: -8, dy: -8).contains(converted) { + return view + } + } + return super.hitTest(point, with: event) + } + @objc private func siteNameTapped() { delegate?.readerPostHeaderView(self, didTap: .siteName) } @@ -347,12 +337,16 @@ public final class ReaderPostHeaderView: UIView { delegate?.readerPostHeaderView(self, didTap: .author) } - @objc private func featuredImageTapped() { - delegate?.readerPostHeaderView(self, didTap: .featuredImage) + @objc private func excerptTapped() { + guard !isExcerptExpanded, let text = fullExcerptText else { return } + isExcerptExpanded = true + let font = displaySettings.font(with: .callout) + let textColor = displaySettings.color.secondaryForeground + excerptLabel.attributedText = NSAttributedString(string: text, attributes: [.font: font, .foregroundColor: textColor]) } - @objc private func viewOriginalTapped() { - delegate?.readerPostHeaderView(self, didTap: .viewOriginal) + @objc private func featuredImageTapped() { + delegate?.readerPostHeaderView(self, didTap: .featuredImage) } private func updateForSizeClass() { @@ -371,7 +365,7 @@ public final class ReaderPostHeaderView: UIView { } else { siteNameLabel.isHidden = true } - siteNameRow.isHidden = siteNameLabel.isHidden && subscribeButton.isHidden + headerRow.isHidden = siteNameLabel.isHidden } private func configureFeaturedImage(with url: URL?) { @@ -400,15 +394,92 @@ public final class ReaderPostHeaderView: UIView { private func configureExcerpt(with excerpt: String?) { if let excerpt, !excerpt.isEmpty { - excerptLabel.text = excerpt + fullExcerptText = excerpt excerptLabel.isHidden = false + lastExcerptLayoutWidth = 0 + updateExcerptTruncation() } else { + fullExcerptText = nil excerptLabel.isHidden = true } } - private func configureReadingTime(with readingTime: String) { - readingTimeLabel.text = readingTime + private func updateExcerptTruncation() { + guard let text = fullExcerptText, !text.isEmpty, !isExcerptExpanded else { return } + + let font = displaySettings.font(with: .callout) + let textColor = displaySettings.color.secondaryForeground + let atttributes: [NSAttributedString.Key: Any] = [.font: font, .foregroundColor: textColor] + let availableWidth = mainStack.bounds.width + + guard availableWidth > 0 else { + excerptLabel.attributedText = NSAttributedString(string: text, attributes: atttributes) + return + } + + let maxHeight = font.lineHeight * CGFloat(Constants.excerptMaxLines) + 1 + + func isEnoughSpace(for string: String, maxHeight: CGFloat) -> Bool { + let height = (string as NSString).boundingRect( + with: CGSize(width: availableWidth, height: .greatestFiniteMagnitude), + options: [.usesLineFragmentOrigin, .usesFontLeading], + attributes: atttributes, + context: nil + ).height + return height <= maxHeight + } + + // Hide under the cut only if there is enough text to warrant it. If there is only one extra + // line, there is no reason to cut it. + if isEnoughSpace(for: text, maxHeight: maxHeight + font.leading * 1) { + excerptLabel.attributedText = NSAttributedString(string: text, attributes: atttributes) + return + } + + let suffix = " " + Strings.viewMore + + // Find the longest prefix that fits with the suffix. + var low = 0, high = text.count, bestCut = 0 + while low <= high { + let mid = (low + high) / 2 + if isEnoughSpace(for: String(text.prefix(mid)) + suffix, maxHeight: maxHeight) { + bestCut = mid + low = mid + 1 + } else { + high = mid - 1 + } + } + + let trimmed = String(text.prefix(bestCut)).trimmingCharacters(in: .whitespacesAndNewlines) + let result = NSMutableAttributedString(string: trimmed, attributes: atttributes) + result.append( + NSAttributedString(string: suffix, attributes: [ + .font: font.withWeight(.regular), + .foregroundColor: UIColor.label, + ]) + ) + excerptLabel.attributedText = result + } + + private func updateSubscribeButtonAppearance() { + let subscribeFont = displaySettings.font(with: .subheadline, weight: .medium) + let colors = displaySettings.color + + if isSubscribed { + // Show "Subscribed" with clear background (current design) + buttonSubscribe.configuration?.attributedTitle = AttributedString( + Strings.subscribed, + attributes: AttributeContainer([.font: subscribeFont]) + ) + buttonSubscribe.configuration?.baseForegroundColor = colors.secondaryForeground + } else { + // Show "Subscribe" with black background to stand out + buttonSubscribe.configuration?.attributedTitle = AttributedString( + Strings.subscribe, + attributes: AttributeContainer([.font: subscribeFont]) + ) + buttonSubscribe.configuration?.baseForegroundColor = UIAppColor.primary + } } } @@ -419,6 +490,7 @@ private extension ReaderPostHeaderView { static let padding: CGFloat = 16 static let avatarSize: CGFloat = 32 static let avatarSizeRegular: CGFloat = 40 + static let excerptMaxLines: Int = 5 static let defaultFeaturedImageAspectRatio: CGFloat = 9.0 / 16.0 static let maxFeaturedImageAspectRatio: CGFloat = 2.0 } @@ -430,10 +502,17 @@ private enum Strings { value: "Subscribe", comment: "Button in the reader post header to subscribe to the site" ) - static let viewOriginal = AppLocalizedString( - "reader.post.header.viewOriginal", - value: "View Original", - comment: "Button in the reader post header to view the original post in a browser" + + static let subscribed = AppLocalizedString( + "reader.post.header.subscribed", + value: "Subscribed", + comment: "Button in the reader post header showing the user is subscribed to the site" + ) + + static let viewMore = AppLocalizedString( + "reader.post.header.viewMore", + value: "\u{2026}view more", + comment: "Appended to the truncated excerpt in the reader post header to indicate more content is available" ) } diff --git a/Modules/Sources/WordPressReader/ReaderReadTime.swift b/Modules/Sources/WordPressReader/ReaderReadTime.swift new file mode 100644 index 000000000000..7ba80b2ee224 --- /dev/null +++ b/Modules/Sources/WordPressReader/ReaderReadTime.swift @@ -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 { + // 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: #/" + 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 + "" + #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 = "

Getting Started with Swift Concurrency

" + post += "

" + String(repeating: "This is a detailed explanation of the concept. ", count: 100) + "

" + post += "" + post += "

" + String(repeating: "Here we explore another important aspect of the topic. ", count: 100) + "

" + post += "" + post += "
```func fetchData() async throws { let data = try await URLSession.shared.data(from: url)```
" + post += "

" + String(repeating: "In conclusion this wraps up the discussion nicely. ", count: 50) + "

" + // ~2500 words / 200 WPM ≈ 12.5 min + image/code penalties → ~13 min + let result = ReaderReadTime.compute(post) + #expect(result == 12) + } +} diff --git a/Sources/WordPressData/Mapping/ReaderPost+Mapping.swift b/Sources/WordPressData/Mapping/ReaderPost+Mapping.swift index 8c7e26e41a6b..01dba0c9b612 100644 --- a/Sources/WordPressData/Mapping/ReaderPost+Mapping.swift +++ b/Sources/WordPressData/Mapping/ReaderPost+Mapping.swift @@ -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 diff --git a/WordPress/Classes/Models/ReaderPost+Swift.swift b/WordPress/Classes/Models/ReaderPost+Swift.swift index 2f27fd2d3c38..cb30e264f7ba 100644 --- a/WordPress/Classes/Models/ReaderPost+Swift.swift +++ b/WordPress/Classes/Models/ReaderPost+Swift.swift @@ -1,5 +1,6 @@ import Foundation import WordPressData +import WordPressReader import WordPressUI import SwiftSoup @@ -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? { + 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 { """ diff --git a/WordPress/Classes/ViewRelated/Reader/Detail/ReaderDetailCoordinator.swift b/WordPress/Classes/ViewRelated/Reader/Detail/ReaderDetailCoordinator.swift index c81667b9eb06..478536a27f1b 100644 --- a/WordPress/Classes/ViewRelated/Reader/Detail/ReaderDetailCoordinator.swift +++ b/WordPress/Classes/ViewRelated/Reader/Detail/ReaderDetailCoordinator.swift @@ -1,4 +1,7 @@ +import AsyncImageKit import Foundation +import SafariServices +import SwiftUI import WordPressData import WordPressReader import WordPressShared @@ -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) @@ -534,7 +537,7 @@ class ReaderDetailCoordinator { scrollToHashIfNeeded() } - private func followSite(completion: @escaping () -> Void) { + private func toggleFollowSite(completion: @escaping () -> Void) { guard let post else { return } @@ -691,26 +694,47 @@ class ReaderDetailCoordinator { } } -// MARK: - ReaderDetailHeaderViewDelegate -extension ReaderDetailCoordinator: ReaderDetailHeaderViewDelegate { - func didTapBlogName() { - previewSite() - } - - func didTapTagButton() { - showTag() - } - - func didTapHeaderAvatar() { - previewSite() +// MARK: - ReaderPostHeaderViewDelegate +extension ReaderDetailCoordinator: ReaderPostHeaderViewDelegate { + func readerPostHeaderView(_ view: ReaderPostHeaderView, didTap element: ReaderPostHeaderView.Element) { + switch element { + case .siteName: + previewSite() + case .subscribe: + view.isShowingSubscribeLoadingIndicator = true + toggleFollowSite { [weak self] in + view.isShowingSubscribeLoadingIndicator = false + self?.view?.updateHeader() + } + case .author: + showAuthorProfile() + case .featuredImage: + showFeaturedImage(view.featuredImageView) + } } - func didTapFollowButton(completion: @escaping () -> Void) { - followSite(completion: completion) + 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 didSelectTopic(_ topic: String) { - showTopic(topic) + 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) } } diff --git a/WordPress/Classes/ViewRelated/Reader/Detail/ReaderDetailViewController.storyboard b/WordPress/Classes/ViewRelated/Reader/Detail/ReaderDetailViewController.storyboard index 3012a436897a..7f696d2f23e1 100644 --- a/WordPress/Classes/ViewRelated/Reader/Detail/ReaderDetailViewController.storyboard +++ b/WordPress/Classes/ViewRelated/Reader/Detail/ReaderDetailViewController.storyboard @@ -39,6 +39,12 @@ + + + + + + @@ -108,7 +114,7 @@ - + @@ -121,7 +127,10 @@ - + + + + @@ -149,6 +158,7 @@ + diff --git a/WordPress/Classes/ViewRelated/Reader/Detail/ReaderDetailViewController.swift b/WordPress/Classes/ViewRelated/Reader/Detail/ReaderDetailViewController.swift index 8191c8b9ea70..b095261dbe04 100644 --- a/WordPress/Classes/ViewRelated/Reader/Detail/ReaderDetailViewController.swift +++ b/WordPress/Classes/ViewRelated/Reader/Detail/ReaderDetailViewController.swift @@ -62,6 +62,9 @@ class ReaderDetailViewController: UIViewController, ReaderDetailView { /// Wrapper for the Likes summary view @IBOutlet weak var likesContainerView: UIView! + /// Wrapper for the Tags collection view + @IBOutlet weak var tagsContainerView: UIView! + /// The loading view, which contains all the ghost views @IBOutlet weak var actionStackView: UIStackView! @@ -71,7 +74,9 @@ class ReaderDetailViewController: UIViewController, ReaderDetailView { private let activityIndicator = UIActivityIndicatorView(style: .medium) /// The actual header - private lazy var header = ReaderDetailHeaderHostingView() + private lazy var header = ReaderPostHeaderView() + private var cachedExcerpt: String? + private var cachedReadingTime: String? /// Bottom toolbar helper private lazy var toolbar = ReaderDetailToolbar() @@ -84,6 +89,11 @@ class ReaderDetailViewController: UIViewController, ReaderDetailView { /// Likes summary view private let likesSummary: ReaderDetailLikesView = .loadFromNib() + /// Tags collection view + private lazy var tagsCollectionView: TopicsCollectionView = { + TopicsCollectionView(frame: .zero, collectionViewLayout: UICollectionViewFlowLayout()) + }() + /// View used to show errors private let noResultsViewController = NoResultsViewController.controller() @@ -251,7 +261,10 @@ class ReaderDetailViewController: UIViewController, ReaderDetailView { toolbar.configure(for: post, in: self) updateToolbarItems() - header.configure(for: post) + cachedExcerpt = post.getUserProvidedExcerpt() + cachedReadingTime = Self.readingTimeString(for: post) + configureHeaderView(with: post) + updateTagsView(with: post) fetchLikes() fetchComments() checkTranslationAvailability() @@ -267,6 +280,10 @@ class ReaderDetailViewController: UIViewController, ReaderDetailView { self?.showPostContent(post) } + if post.isFollowing { + header.buttonSubscribe.isHidden = true + } + navigateToCommentIfNecessary() } @@ -363,7 +380,7 @@ class ReaderDetailViewController: UIViewController, ReaderDetailView { } private var allContentViews: [UIView] { - [webView, likesContainerView, commentsTableView, relatedPostsTableView, actionStackView] + [webView, tagsContainerView, likesContainerView, commentsTableView, relatedPostsTableView, actionStackView] } func fetchRelatedPostsIfNeeded(for post: ReaderPost) { @@ -401,7 +418,8 @@ class ReaderDetailViewController: UIViewController, ReaderDetailView { } func updateHeader() { - header.refreshFollowButton() + guard let post else { return } + header.isSubscribed = post.isFollowing } func updateLikesView(with viewModel: ReaderDetailLikesViewModel) { @@ -490,7 +508,7 @@ class ReaderDetailViewController: UIViewController, ReaderDetailView { headerContainerView.backgroundColor = displaySetting.color.background // Header view - header.displaySetting = displaySetting + header.apply(displaySetting) } // Update Reader Post web view @@ -549,14 +567,39 @@ class ReaderDetailViewController: UIViewController, ReaderDetailView { } private func configureHeader() { - header.displaySetting = displaySetting + header.apply(displaySetting) header.delegate = coordinator + header.translatesAutoresizingMaskIntoConstraints = false headerContainerView.addSubview(header) - headerContainerView.translatesAutoresizingMaskIntoConstraints = false - headerContainerView.pinSubviewToAllEdges(header) } + private func configureHeaderView(with post: ReaderPost, customTitle: String? = nil) { + let featuredImageURL: URL? = post.contentIncludesFeaturedImage() ? nil : post.featuredImageURLForDisplay() + header.configure(with: ReaderPostHeaderView.ViewModel( + siteName: post.blogNameForDisplay(), + postTitle: customTitle ?? post.titleForDisplay(), + authorName: post.authorForDisplay() ?? "", + authorAvatarURL: post.avatarURLForDisplay(), + dateString: post.dateForDisplay()?.mediumStringWithTime(), + featuredImageURL: featuredImageURL, + excerpt: cachedExcerpt, + readingTime: cachedReadingTime ?? "" + )) + updateHeader() + } + + private static func readingTimeString(for post: ReaderPost) -> String { + String.localizedStringWithFormat( + NSLocalizedString( + "reader.detail.header.readingTime", + value: "%1$d min read", + comment: "Estimated reading time for the post. %1$d is the number of minutes." + ), + max(1, post.getEstimatedReadingTime()) + ) + } + private func fetchLikes() { guard let post else { return @@ -586,6 +629,42 @@ class ReaderDetailViewController: UIViewController, ReaderDetailView { view.setNeedsDisplay() } + private func configureTagsCollectionView() { + tagsContainerView.addSubview(tagsCollectionView) + tagsContainerView.translatesAutoresizingMaskIntoConstraints = false + tagsCollectionView.translatesAutoresizingMaskIntoConstraints = false + tagsCollectionView.topicDelegate = self + + NSLayoutConstraint.activate([ + tagsCollectionView.topAnchor.constraint(equalTo: tagsContainerView.topAnchor), + tagsCollectionView.bottomAnchor.constraint(equalTo: tagsContainerView.bottomAnchor), + tagsCollectionView.leadingAnchor.constraint(equalTo: tagsContainerView.leadingAnchor), + tagsCollectionView.trailingAnchor.constraint(lessThanOrEqualTo: tagsContainerView.trailingAnchor) + ]) + } + + private func updateTagsView(with post: ReaderPost) { + let tags = post.tagsForDisplay() + guard !tags.isEmpty else { + hideTagsView() + return + } + + if tagsCollectionView.superview == nil { + configureTagsCollectionView() + } + + tagsCollectionView.topics = tags + scrollView.layoutIfNeeded() + } + + private func hideTagsView() { + // Because other components are constrained to the tagsContainerView, simply hiding it leaves a gap. + tagsCollectionView.removeFromSuperview() + tagsContainerView.frame.size.height = 0 + view.setNeedsDisplay() + } + @objc private func fetchComments() { guard let post else { return @@ -687,7 +766,7 @@ class ReaderDetailViewController: UIViewController, ReaderDetailView { blurView.removeFromSuperview() } - header.configure(for: post, title: translationResults[0]) + configureHeaderView(with: post, customTitle: translationResults[0]) do { try await webView.setBodyHTML(translationResults[1]) } catch { @@ -897,7 +976,7 @@ class ReaderDetailViewController: UIViewController, ReaderDetailView { let refreshed = notification.userInfo?[NSRefreshedObjectsKey] as? Set ?? Set() if updated.contains(post) || refreshed.contains(post) { - header.configure(for: post) + configureHeaderView(with: post) } } } @@ -1050,6 +1129,18 @@ extension ReaderDetailViewController: UIGestureRecognizerDelegate { } } +// MARK: - ReaderTopicCollectionViewCoordinatorDelegate + +extension ReaderDetailViewController: ReaderTopicCollectionViewCoordinatorDelegate { + func coordinator(_ coordinator: ReaderTopicCollectionViewCoordinator, didSelectTopic topic: String) { + self.coordinator?.showTopic(topic) + } + + func coordinator(_ coordinator: ReaderTopicCollectionViewCoordinator, didChangeState: ReaderTopicCollectionViewState) { + // Handle state changes if needed (expand/collapse) + } +} + // MARK: - Reader Card Discover extension ReaderDetailViewController: ReaderCardDiscoverAttributionViewDelegate { diff --git a/WordPress/Classes/ViewRelated/Reader/User/ReaderUserProfileView.swift b/WordPress/Classes/ViewRelated/Reader/User/ReaderUserProfileView.swift index 33f870d8cfe1..045b4ebb88e0 100644 --- a/WordPress/Classes/ViewRelated/Reader/User/ReaderUserProfileView.swift +++ b/WordPress/Classes/ViewRelated/Reader/User/ReaderUserProfileView.swift @@ -56,6 +56,12 @@ struct ReaderUserProfileViewModel { self.name = comment.author self.siteURL = URL(string: comment.author_url) } + + init(post: ReaderPost) { + self.avatarURL = post.avatarURLForDisplay() + self.name = post.authorForDisplay() ?? "" + self.siteURL = post.blogURL.flatMap(URL.init(string:)) + } } private enum Strings { From f62f0477aa2d4459966c59dda79b5cef99f78794 Mon Sep 17 00:00:00 2001 From: Tony Li Date: Tue, 26 May 2026 13:02:36 +1200 Subject: [PATCH 2/8] Fix a typo Co-authored-by: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> --- Modules/Sources/WordPressReader/ReaderReadTime.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Modules/Sources/WordPressReader/ReaderReadTime.swift b/Modules/Sources/WordPressReader/ReaderReadTime.swift index 7ba80b2ee224..25ef28b49ff4 100644 --- a/Modules/Sources/WordPressReader/ReaderReadTime.swift +++ b/Modules/Sources/WordPressReader/ReaderReadTime.swift @@ -6,7 +6,7 @@ public enum ReaderReadTime { /// /// - Parameters: /// - text: The raw post content (may contain HTML/Markdown). - /// - wpm: Words per minute reading speed (default 238). + /// - 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 { // 1. Strip HTML & Markdown From ce10352321c4d593ed7c8b60fe25b5be6b289fd2 Mon Sep 17 00:00:00 2001 From: Tony Li Date: Tue, 26 May 2026 13:35:57 +1200 Subject: [PATCH 3/8] Update an unit test --- Modules/Tests/WordPressReaderTests/ReaderReadTimeTests.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Modules/Tests/WordPressReaderTests/ReaderReadTimeTests.swift b/Modules/Tests/WordPressReaderTests/ReaderReadTimeTests.swift index 47002c8a4256..46cab2934911 100644 --- a/Modules/Tests/WordPressReaderTests/ReaderReadTimeTests.swift +++ b/Modules/Tests/WordPressReaderTests/ReaderReadTimeTests.swift @@ -40,8 +40,8 @@ struct ReaderReadTimeTests { } @Test func codeBlocksAddPenalty() { - let base = String(repeating: "word ", count: 200) - let withCode = base + "```let x = 1; let y = 2; let z = 3```" + let base = "print 'Hello world'" + let withCode = "```\(base)```" #expect(ReaderReadTime.compute(withCode) >= ReaderReadTime.compute(base)) } From 00c35c5b8c33cb8f14697817345d1338bb927cda Mon Sep 17 00:00:00 2001 From: Tony Li Date: Tue, 26 May 2026 14:01:53 +1200 Subject: [PATCH 4/8] Estimate reading time for scripts without word spaces 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. --- .../WordPressReader/ReaderReadTime.swift | 20 +++++++++++++++---- .../ReaderReadTimeTests.swift | 11 ++++++++-- 2 files changed, 25 insertions(+), 6 deletions(-) diff --git a/Modules/Sources/WordPressReader/ReaderReadTime.swift b/Modules/Sources/WordPressReader/ReaderReadTime.swift index 25ef28b49ff4..d64aa3490308 100644 --- a/Modules/Sources/WordPressReader/ReaderReadTime.swift +++ b/Modules/Sources/WordPressReader/ReaderReadTime.swift @@ -15,19 +15,31 @@ public enum ReaderReadTime { clean = clean.replacing(#/!\[.*?\]\(.*?\)/#, with: "") clean = clean.replacing(#/\[.*?\]\(.*?\)/#, with: " ") - // 2. Count words + // 2. Count characters from scripts that don't put spaces between words + // (Chinese, Japanese, Thai, etc.), then remove them. Otherwise an unspaced + // run collapses into a single "word" below, so they are charged per-character + // in step 4. Hangul is excluded on purpose because Korean uses spaces. + let unspacedScripts = #/[\p{Han}\p{Hiragana}\p{Katakana}\p{Thai}\p{Lao}\p{Khmer}\p{Myanmar}\p{Tibetan}]/# + let unspacedCharCount = clean.matches(of: unspacedScripts).count + clean = clean.replacing(unspacedScripts, with: " ") + + // 3. Count words let wordCount = clean.matches(of: #/\b\w+\b/#).count - // 3. Base reading time (seconds) + // 4. Base reading time (seconds). Unspaced-script characters are charged per + // character at a rate derived from wpm, approximating one word as ~2 such + // characters, so the two rates scale together instead of being independent. + let unspacedCharactersPerMinute = wpm * 2 var totalSeconds = (Double(wordCount) / wpm) * 60 + totalSeconds += (Double(unspacedCharCount) / unspacedCharactersPerMinute) * 60 - // 4. Image penalty (12s → 3s floor, decreasing per image) + // 5. Image penalty (12s → 3s floor, decreasing per image) let imageCount = text.matches(of: #/" post += "" - post += "

" + String(repeating: "Here we explore another important aspect of the topic. ", count: 100) + "

" + post += + "

" + String(repeating: "Here we explore another important aspect of the topic. ", count: 100) + "

" post += "" - post += "
```func fetchData() async throws { let data = try await URLSession.shared.data(from: url)```
" + post += + "
```func fetchData() async throws { let data = try await URLSession.shared.data(from: url)```
" post += "

" + String(repeating: "In conclusion this wraps up the discussion nicely. ", count: 50) + "

" // ~2500 words / 200 WPM ≈ 12.5 min + image/code penalties → ~13 min let result = ReaderReadTime.compute(post) #expect(result == 12) } + + @Test func cjkTextProducesReasonableEstimate() { + let text = String(repeating: "测试句子", count: 125) // 500 chars + #expect(ReaderReadTime.compute(text) > 1) + } } From b96296664391ef5448d84ed2a1700a33a651b5ef Mon Sep 17 00:00:00 2001 From: Tony Li Date: Mon, 22 Jun 2026 19:31:03 +1200 Subject: [PATCH 5/8] Remove unused reading time estimation from the Reader post header 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. --- .../Detail/ReaderPostHeaderView.swift | 23 ++----- .../WordPressReader/ReaderReadTime.swift | 51 -------------- .../ReaderReadTimeTests.swift | 68 ------------------- .../Classes/Models/ReaderPost+Swift.swift | 14 ---- .../Detail/ReaderDetailViewController.swift | 16 +---- 5 files changed, 8 insertions(+), 164 deletions(-) delete mode 100644 Modules/Sources/WordPressReader/ReaderReadTime.swift delete mode 100644 Modules/Tests/WordPressReaderTests/ReaderReadTimeTests.swift diff --git a/Modules/Sources/WordPressReader/Detail/ReaderPostHeaderView.swift b/Modules/Sources/WordPressReader/Detail/ReaderPostHeaderView.swift index d99b61118c4e..9c97e45ec2ee 100644 --- a/Modules/Sources/WordPressReader/Detail/ReaderPostHeaderView.swift +++ b/Modules/Sources/WordPressReader/Detail/ReaderPostHeaderView.swift @@ -31,7 +31,6 @@ public final class ReaderPostHeaderView: UIView { public let dateString: String? public let featuredImageURL: URL? public let excerpt: String? - public let readingTime: String public init( siteName: String? = nil, @@ -40,8 +39,7 @@ public final class ReaderPostHeaderView: UIView { authorAvatarURL: URL? = nil, dateString: String?, featuredImageURL: URL? = nil, - excerpt: String? = nil, - readingTime: String + excerpt: String? = nil ) { self.siteName = siteName self.postTitle = postTitle @@ -50,7 +48,6 @@ public final class ReaderPostHeaderView: UIView { self.dateString = dateString self.featuredImageURL = featuredImageURL self.excerpt = excerpt - self.readingTime = readingTime } } @@ -527,8 +524,7 @@ private enum Strings { authorAvatarURL: URL(string: "https://picsum.photos/id/237/120/120.jpg"), dateString: "Dec 18, 2025 at 3:30 PM", featuredImageURL: URL(string: "https://automattic.design/wp-content/uploads/2025/12/a8ch25_zoom-bg-1.png?w=1024"), - excerpt: "Based in a small city in Northern Spain, Cinta Arribas is an illustrator and visual artist with over ten years of professional experience. She studied Fine Arts in Salamanca and Kassel (Germany), and recently completed an artist residency in Washington, DC, through a program of the Spanish Embassy.", - readingTime: "5 min read" + excerpt: "Based in a small city in Northern Spain, Cinta Arribas is an illustrator and visual artist with over ten years of professional experience. She studied Fine Arts in Salamanca and Kassel (Germany), and recently completed an artist residency in Washington, DC, through a program of the Spanish Embassy." ))) } @@ -540,8 +536,7 @@ private enum Strings { authorName: "Roosmarijn van Kessel", authorAvatarURL: URL(string: "https://picsum.photos/id/237/120/120.jpg"), dateString: "Dec 18, 2025 at 3:30 PM", - excerpt: "Based in a small city in Northern Spain, Cinta Arribas is an illustrator and visual artist with over ten years of professional experience. She studied Fine Arts in Salamanca and Kassel (Germany), and recently completed an artist residency in Washington, DC, through a program of the Spanish Embassy.", - readingTime: "5 min read" + excerpt: "Based in a small city in Northern Spain, Cinta Arribas is an illustrator and visual artist with over ten years of professional experience. She studied Fine Arts in Salamanca and Kassel (Germany), and recently completed an artist residency in Washington, DC, through a program of the Spanish Embassy." ))) } @@ -553,8 +548,7 @@ private enum Strings { authorName: "Roosmarijn van Kessel", authorAvatarURL: URL(string: "https://picsum.photos/id/237/120/120.jpg"), dateString: "Dec 18, 2025 at 3:30 PM", - featuredImageURL: URL(string: "https://automattic.design/wp-content/uploads/2025/12/a8ch25_zoom-bg-1.png?w=1024"), - readingTime: "3 min read" + featuredImageURL: URL(string: "https://automattic.design/wp-content/uploads/2025/12/a8ch25_zoom-bg-1.png?w=1024") ))) } @@ -567,8 +561,7 @@ private enum Strings { authorAvatarURL: URL(string: "https://picsum.photos/id/237/120/120.jpg"), dateString: "Dec 18, 2025 at 3:30 PM", featuredImageURL: URL(string: "https://automattic.design/wp-content/uploads/2025/12/a8ch25_zoom-bg-1.png?w=1024"), - excerpt: "Based in a small city in Northern Spain, Cinta Arribas is an illustrator and visual artist with over ten years of professional experience. She studied Fine Arts in Salamanca and Kassel (Germany), and recently completed an artist residency in Washington, DC, through a program of the Spanish Embassy. Her work has been featured in numerous publications and exhibitions across Europe and the Americas. She specializes in editorial illustration, children's books, and cultural event posters, bringing a unique blend of traditional and contemporary techniques to every project she undertakes.", - readingTime: "12 min read" + excerpt: "Based in a small city in Northern Spain, Cinta Arribas is an illustrator and visual artist with over ten years of professional experience. She studied Fine Arts in Salamanca and Kassel (Germany), and recently completed an artist residency in Washington, DC, through a program of the Spanish Embassy. Her work has been featured in numerous publications and exhibitions across Europe and the Americas. She specializes in editorial illustration, children's books, and cultural event posters, bringing a unique blend of traditional and contemporary techniques to every project she undertakes." ))) } @@ -581,8 +574,7 @@ private enum Strings { authorAvatarURL: URL(string: "https://picsum.photos/id/237/120/120.jpg"), dateString: "Dec 18, 2025 at 3:30 PM", featuredImageURL: URL(string: "https://automattic.design/wp-content/uploads/2025/12/aecc_stars.png"), - excerpt: "Based in a small city in Northern Spain, Cinta Arribas is an illustrator and visual artist with over ten years of professional experience.", - readingTime: "5 min read" + excerpt: "Based in a small city in Northern Spain, Cinta Arribas is an illustrator and visual artist with over ten years of professional experience." ))) } @@ -593,7 +585,6 @@ private enum Strings { postTitle: "Hello World", authorName: "admin", authorAvatarURL: URL(string: "https://picsum.photos/id/237/120/120.jpg"), - dateString: "Mar 1, 2026", - readingTime: "1 min read" + dateString: "Mar 1, 2026" ))) } diff --git a/Modules/Sources/WordPressReader/ReaderReadTime.swift b/Modules/Sources/WordPressReader/ReaderReadTime.swift deleted file mode 100644 index d64aa3490308..000000000000 --- a/Modules/Sources/WordPressReader/ReaderReadTime.swift +++ /dev/null @@ -1,51 +0,0 @@ -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 200). - /// - Returns: Estimated reading time in minutes (minimum 1). - public static func compute(_ text: String, wpm: Double = 200) -> Int { - // 1. Strip HTML & Markdown - var clean = text - clean = clean.replacing(#/<[^>]+>/#, with: "") - clean = clean.replacing(#/!\[.*?\]\(.*?\)/#, with: "") - clean = clean.replacing(#/\[.*?\]\(.*?\)/#, with: " ") - - // 2. Count characters from scripts that don't put spaces between words - // (Chinese, Japanese, Thai, etc.), then remove them. Otherwise an unspaced - // run collapses into a single "word" below, so they are charged per-character - // in step 4. Hangul is excluded on purpose because Korean uses spaces. - let unspacedScripts = #/[\p{Han}\p{Hiragana}\p{Katakana}\p{Thai}\p{Lao}\p{Khmer}\p{Myanmar}\p{Tibetan}]/# - let unspacedCharCount = clean.matches(of: unspacedScripts).count - clean = clean.replacing(unspacedScripts, with: " ") - - // 3. Count words - let wordCount = clean.matches(of: #/\b\w+\b/#).count - - // 4. Base reading time (seconds). Unspaced-script characters are charged per - // character at a rate derived from wpm, approximating one word as ~2 such - // characters, so the two rates scale together instead of being independent. - let unspacedCharactersPerMinute = wpm * 2 - var totalSeconds = (Double(wordCount) / wpm) * 60 - totalSeconds += (Double(unspacedCharCount) / unspacedCharactersPerMinute) * 60 - - // 5. Image penalty (12s → 3s floor, decreasing per image) - let imageCount = text.matches(of: #/" - 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 + "" - #expect(ReaderReadTime.compute(base) == 1) - #expect(ReaderReadTime.compute(withImages) == 2) - } - - @Test func codeBlocksAddPenalty() { - let base = "print 'Hello world'" - let withCode = "```\(base)```" - #expect(ReaderReadTime.compute(withCode) >= ReaderReadTime.compute(base)) - } - - @Test func longPost() { - // ~2500 word blog post with HTML, images, and code - var post = "

Getting Started with Swift Concurrency

" - post += "

" + String(repeating: "This is a detailed explanation of the concept. ", count: 100) + "

" - post += "" - post += - "

" + String(repeating: "Here we explore another important aspect of the topic. ", count: 100) + "

" - post += "" - post += - "
```func fetchData() async throws { let data = try await URLSession.shared.data(from: url)```
" - post += "

" + String(repeating: "In conclusion this wraps up the discussion nicely. ", count: 50) + "

" - // ~2500 words / 200 WPM ≈ 12.5 min + image/code penalties → ~13 min - let result = ReaderReadTime.compute(post) - #expect(result == 12) - } - - @Test func cjkTextProducesReasonableEstimate() { - let text = String(repeating: "测试句子", count: 125) // 500 chars - #expect(ReaderReadTime.compute(text) > 1) - } -} diff --git a/WordPress/Classes/Models/ReaderPost+Swift.swift b/WordPress/Classes/Models/ReaderPost+Swift.swift index cb30e264f7ba..541123e4384a 100644 --- a/WordPress/Classes/Models/ReaderPost+Swift.swift +++ b/WordPress/Classes/Models/ReaderPost+Swift.swift @@ -64,20 +64,6 @@ 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 diff --git a/WordPress/Classes/ViewRelated/Reader/Detail/ReaderDetailViewController.swift b/WordPress/Classes/ViewRelated/Reader/Detail/ReaderDetailViewController.swift index b095261dbe04..716503803994 100644 --- a/WordPress/Classes/ViewRelated/Reader/Detail/ReaderDetailViewController.swift +++ b/WordPress/Classes/ViewRelated/Reader/Detail/ReaderDetailViewController.swift @@ -76,7 +76,6 @@ class ReaderDetailViewController: UIViewController, ReaderDetailView { /// The actual header private lazy var header = ReaderPostHeaderView() private var cachedExcerpt: String? - private var cachedReadingTime: String? /// Bottom toolbar helper private lazy var toolbar = ReaderDetailToolbar() @@ -262,7 +261,6 @@ class ReaderDetailViewController: UIViewController, ReaderDetailView { toolbar.configure(for: post, in: self) updateToolbarItems() cachedExcerpt = post.getUserProvidedExcerpt() - cachedReadingTime = Self.readingTimeString(for: post) configureHeaderView(with: post) updateTagsView(with: post) fetchLikes() @@ -583,23 +581,11 @@ class ReaderDetailViewController: UIViewController, ReaderDetailView { authorAvatarURL: post.avatarURLForDisplay(), dateString: post.dateForDisplay()?.mediumStringWithTime(), featuredImageURL: featuredImageURL, - excerpt: cachedExcerpt, - readingTime: cachedReadingTime ?? "" + excerpt: cachedExcerpt )) updateHeader() } - private static func readingTimeString(for post: ReaderPost) -> String { - String.localizedStringWithFormat( - NSLocalizedString( - "reader.detail.header.readingTime", - value: "%1$d min read", - comment: "Estimated reading time for the post. %1$d is the number of minutes." - ), - max(1, post.getEstimatedReadingTime()) - ) - } - private func fetchLikes() { guard let post else { return From e7675ce86db5b3b07e7e32209306bd8737c14c55 Mon Sep 17 00:00:00 2001 From: Tony Li Date: Mon, 22 Jun 2026 19:42:59 +1200 Subject: [PATCH 6/8] Keep the Reader header subscribe button visible for followed sites 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. --- .../Reader/Detail/ReaderDetailViewController.swift | 4 ---- 1 file changed, 4 deletions(-) diff --git a/WordPress/Classes/ViewRelated/Reader/Detail/ReaderDetailViewController.swift b/WordPress/Classes/ViewRelated/Reader/Detail/ReaderDetailViewController.swift index 716503803994..8ec032e89c1f 100644 --- a/WordPress/Classes/ViewRelated/Reader/Detail/ReaderDetailViewController.swift +++ b/WordPress/Classes/ViewRelated/Reader/Detail/ReaderDetailViewController.swift @@ -278,10 +278,6 @@ class ReaderDetailViewController: UIViewController, ReaderDetailView { self?.showPostContent(post) } - if post.isFollowing { - header.buttonSubscribe.isHidden = true - } - navigateToCommentIfNecessary() } From 446af4f63b27aa4f04bae8519b58f108e919e717 Mon Sep 17 00:00:00 2001 From: Tony Li Date: Mon, 22 Jun 2026 19:43:07 +1200 Subject: [PATCH 7/8] Fix Reader header excerpt theming and author row accessibility 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. --- .../Detail/ReaderPostHeaderView.swift | 23 ++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/Modules/Sources/WordPressReader/Detail/ReaderPostHeaderView.swift b/Modules/Sources/WordPressReader/Detail/ReaderPostHeaderView.swift index 9c97e45ec2ee..86a9479c12ef 100644 --- a/Modules/Sources/WordPressReader/Detail/ReaderPostHeaderView.swift +++ b/Modules/Sources/WordPressReader/Detail/ReaderPostHeaderView.swift @@ -220,6 +220,10 @@ public final class ReaderPostHeaderView: UIView { authorNameLabel.text = viewModel.authorName dateLabel.text = viewModel.dateString dateLabel.isHidden = viewModel.dateString == nil + authorRow.accessibilityLabel = [viewModel.authorName, viewModel.dateString] + .compactMap { $0 } + .filter { !$0.isEmpty } + .joined(separator: ", ") if let avatarURL = viewModel.authorAvatarURL { avatarImageView.wp.setImage(with: avatarURL) @@ -286,11 +290,16 @@ public final class ReaderPostHeaderView: UIView { siteNameLabel.isUserInteractionEnabled = true siteNameLabel.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(siteNameTapped))) + siteNameLabel.accessibilityTraits = .button + siteNameLabel.accessibilityHint = Strings.siteAccessibilityHint buttonSubscribe.addTarget(self, action: #selector(subscribeTapped), for: .touchUpInside) authorRow.isUserInteractionEnabled = true authorRow.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(authorTapped))) + authorRow.isAccessibilityElement = true + authorRow.accessibilityTraits = .button + authorRow.accessibilityHint = Strings.authorAccessibilityHint excerptLabel.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(excerptTapped))) @@ -452,7 +461,7 @@ public final class ReaderPostHeaderView: UIView { result.append( NSAttributedString(string: suffix, attributes: [ .font: font.withWeight(.regular), - .foregroundColor: UIColor.label, + .foregroundColor: displaySettings.color.foreground, ]) ) excerptLabel.attributedText = result @@ -511,6 +520,18 @@ private enum Strings { value: "\u{2026}view more", comment: "Appended to the truncated excerpt in the reader post header to indicate more content is available" ) + + static let siteAccessibilityHint = AppLocalizedString( + "reader.post.header.site.a11yHint", + value: "Views posts from the site", + comment: "Accessibility hint for the site name in the reader post header. Tapping it shows the site's posts." + ) + + static let authorAccessibilityHint = AppLocalizedString( + "reader.post.header.author.a11yHint", + value: "Views the author's profile", + comment: "Accessibility hint for the author row in the reader post header. Tapping it shows the author's profile." + ) } // MARK: - Preview From 42e2e019c14580c7e5615674723ed4a05dee4835 Mon Sep 17 00:00:00 2001 From: Tony Li Date: Mon, 22 Jun 2026 20:19:09 +1200 Subject: [PATCH 8/8] Track image taps on the Reader header featured image 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. --- .../ViewRelated/Reader/Detail/ReaderDetailCoordinator.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/WordPress/Classes/ViewRelated/Reader/Detail/ReaderDetailCoordinator.swift b/WordPress/Classes/ViewRelated/Reader/Detail/ReaderDetailCoordinator.swift index 478536a27f1b..65ce64a249d9 100644 --- a/WordPress/Classes/ViewRelated/Reader/Detail/ReaderDetailCoordinator.swift +++ b/WordPress/Classes/ViewRelated/Reader/Detail/ReaderDetailCoordinator.swift @@ -701,7 +701,7 @@ extension ReaderDetailCoordinator: ReaderPostHeaderViewDelegate { case .siteName: previewSite() case .subscribe: - view.isShowingSubscribeLoadingIndicator = true + view.isShowingSubscribeLoadingIndicator = true toggleFollowSite { [weak self] in view.isShowingSubscribeLoadingIndicator = false self?.view?.updateHeader() @@ -717,6 +717,7 @@ extension ReaderDetailCoordinator: ReaderPostHeaderViewDelegate { guard let post, let imageURL = post.featuredImage.flatMap(URL.init) else { return } + WPAnalytics.trackReader(.readerArticleImageTapped) let lightboxVC = LightboxViewController(sourceURL: imageURL, host: MediaHost(post)) MainActor.assumeIsolated { lightboxVC.thumbnail = sender.image