From c25e658be6db3e01b5f3e750421c4d2f7c798c65 Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Mon, 30 Mar 2026 15:33:34 -0400 Subject: [PATCH] Integrate ReaderPostHeaderView --- .../WordPressKitObjC/RemoteReaderPost.m | 1 + .../include/RemoteReaderPost.h | 2 + .../Detail/ReaderPostHeaderView.swift | 262 +++++++++++++----- .../WordPressReader/ReaderReadTime.swift | 39 +++ .../ReaderReadTimeTests.swift | 61 ++++ .../Mapping/ReaderPost+Mapping.swift | 1 + .../Classes/Models/ReaderPost+Swift.swift | 44 +++ .../Detail/ReaderDetailCoordinator.swift | 88 +++++- .../ReaderDetailViewController.storyboard | 14 +- .../Detail/ReaderDetailViewController.swift | 107 ++++++- .../Reader/User/ReaderUserProfileView.swift | 6 + 11 files changed, 534 insertions(+), 91 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..09ce41d3d3df 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? @@ -65,11 +65,11 @@ public final class ReaderPostHeaderView: UIView { return label }() - public let subscribeButton: UIButton = { - var config = UIButton.Configuration.plain() - config.contentInsets = NSDirectionalEdgeInsets(top: 4, leading: 12, bottom: 4, trailing: 12) - config.cornerStyle = .capsule - config.background.strokeWidth = 1 + public let buttonSubscribe: UIButton = { + var config = UIButton.Configuration.bordered() + config.contentInsets = NSDirectionalEdgeInsets(top: 6, leading: 12, bottom: 6, trailing: 12) + config.cornerStyle = .medium + config.baseBackgroundColor = .clear let button = UIButton(configuration: config) button.maximumContentSizeCategory = .extraExtraExtraLarge button.setContentHuggingPriority(.required, for: .horizontal) @@ -77,15 +77,11 @@ 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 subscribeLoadingIndicator: UIActivityIndicatorView = { + let indicator = UIActivityIndicatorView(style: .medium) + indicator.hidesWhenStopped = true + indicator.translatesAutoresizingMaskIntoConstraints = false + return indicator }() private let titleLabel: UITextView = { @@ -138,6 +134,7 @@ public final class ReaderPostHeaderView: UIView { let label = UILabel() label.numberOfLines = 0 label.adjustsFontForContentSizeCategory = true + label.isUserInteractionEnabled = true label.isHidden = true return label }() @@ -161,12 +158,11 @@ public final class ReaderPostHeaderView: UIView { // 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 siteNameRow: UIView = { + let container = UIView() + container.addSubview(siteNameLabel) + siteNameLabel.pinEdges([.leading, .vertical]) + return container }() private lazy var authorTextStack: UIStackView = { @@ -195,9 +191,9 @@ public final class ReaderPostHeaderView: UIView { private lazy var footerRow: UIStackView = { let spacer = UIView() spacer.setContentHuggingPriority(.defaultLow, for: .horizontal) - let stack = UIStackView(arrangedSubviews: [readingTimeStack, spacer, viewOriginalButton]) + let stack = UIStackView(arrangedSubviews: [readingTimeStack, spacer, buttonSubscribe, subscribeLoadingIndicator]) stack.axis = .horizontal - stack.alignment = .center + stack.alignment = .top stack.spacing = 8 return stack }() @@ -212,6 +208,7 @@ public final class ReaderPostHeaderView: UIView { separator, footerRow ]) + stack.setCustomSpacing(9, after: separator) stack.axis = .vertical stack.spacing = 12 return stack @@ -219,7 +216,31 @@ 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.alpha = isShowingSubscribeLoadingIndicator ? 0.0 : 1.0 + buttonSubscribe.isEnabled = !isShowingSubscribeLoadingIndicator + + if isShowingSubscribeLoadingIndicator { + subscribeLoadingIndicator.startAnimating() + } else { + subscribeLoadingIndicator.stopAnimating() + } + } + } // MARK: - Init @@ -246,68 +267,62 @@ 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.font = displaySettings.font(with: .footnote) readingTimeLabel.textColor = colors.secondaryForeground - let iconConfig = UIImage.SymbolConfiguration(font: displaySetting.font(with: .caption1)) + let iconConfig = UIImage.SymbolConfiguration(font: displaySettings.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 + + subscribeLoadingIndicator.color = colors.secondaryForeground + + 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(12, after: featuredImageView) avatarSizeConstraints = [ avatarImageView.widthAnchor.constraint(equalToConstant: Constants.avatarSize), @@ -322,19 +337,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 +384,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 +412,7 @@ public final class ReaderPostHeaderView: UIView { } else { siteNameLabel.isHidden = true } - siteNameRow.isHidden = siteNameLabel.isHidden && subscribeButton.isHidden + siteNameRow.isHidden = siteNameLabel.isHidden && buttonSubscribe.isHidden } private func configureFeaturedImage(with url: URL?) { @@ -400,16 +441,101 @@ 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 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 configureReadingTime(with readingTime: String) { readingTimeLabel.text = readingTime } + + private func updateSubscribeButtonAppearance() { + let subscribeFont = displaySettings.font(with: .footnote, 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?.baseBackgroundColor = .clear + buttonSubscribe.configuration?.background.strokeColor = colors.secondaryForeground.withAlphaComponent(0.3) + 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?.baseBackgroundColor = .black + buttonSubscribe.configuration?.background.strokeColor = .clear + buttonSubscribe.configuration?.baseForegroundColor = .white + } + } } // MARK: - Constants @@ -419,6 +545,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 +557,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 16b88be2b866..d0a9c2cff27f 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) @@ -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 { @@ -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 } } 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 e5b58352560c..d2e28a896160 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() @@ -363,7 +376,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 +414,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 +504,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 +563,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()?.toMediumString() ?? "", + 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 +625,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 +762,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 +972,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 +1125,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 {