diff --git a/Modules/Sources/WordPressData/Mapping/ReaderPost+Mapping.swift b/Modules/Sources/WordPressData/Mapping/ReaderPost+Mapping.swift index 8c7e26e41a6b..01dba0c9b612 100644 --- a/Modules/Sources/WordPressData/Mapping/ReaderPost+Mapping.swift +++ b/Modules/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/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..86a9479c12ef 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,20 +28,18 @@ 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 public init( siteName: String? = nil, postTitle: String, authorName: String, authorAvatarURL: URL? = nil, - dateString: String, + 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 } } @@ -65,11 +62,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 +73,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 +123,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 +164,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 +181,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 +219,11 @@ public final class ReaderPostHeaderView: UIView { titleLabel.text = viewModel.postTitle 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) @@ -246,68 +231,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), @@ -321,20 +290,47 @@ public final class ReaderPostHeaderView: UIView { siteNameLabel.isUserInteractionEnabled = true siteNameLabel.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(siteNameTapped))) + siteNameLabel.accessibilityTraits = .button + siteNameLabel.accessibilityHint = Strings.siteAccessibilityHint - 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))) + authorRow.isAccessibilityElement = true + authorRow.accessibilityTraits = .button + authorRow.accessibilityHint = Strings.authorAccessibilityHint + + 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 +343,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 +371,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 +400,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: displaySettings.color.foreground, + ]) + ) + 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 +496,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 +508,29 @@ 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" + ) + + 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." ) } @@ -448,8 +545,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." ))) } @@ -461,8 +557,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." ))) } @@ -474,8 +569,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") ))) } @@ -488,8 +582,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." ))) } @@ -502,8 +595,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." ))) } @@ -514,7 +606,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/WordPress/Classes/Models/ReaderPost+Swift.swift b/WordPress/Classes/Models/ReaderPost+Swift.swift index 2f27fd2d3c38..541123e4384a 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,35 @@ extension ReaderPost { try? lookup(withID: postID, forSiteWithID: siteID, in: context) } + /// 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..65ce64a249d9 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,48 @@ 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 + } + WPAnalytics.trackReader(.readerArticleImageTapped) + 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..8ec032e89c1f 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,8 @@ 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? /// Bottom toolbar helper private lazy var toolbar = ReaderDetailToolbar() @@ -84,6 +88,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 +260,9 @@ class ReaderDetailViewController: UIViewController, ReaderDetailView { toolbar.configure(for: post, in: self) updateToolbarItems() - header.configure(for: post) + cachedExcerpt = post.getUserProvidedExcerpt() + configureHeaderView(with: post) + updateTagsView(with: post) fetchLikes() fetchComments() checkTranslationAvailability() @@ -363,7 +374,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 +412,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 +502,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 +561,27 @@ 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 + )) + updateHeader() + } + private func fetchLikes() { guard let post else { return @@ -586,6 +611,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 +748,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 +958,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 +1111,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 {