diff --git a/WordPress/Classes/Utility/Analytics/WPAnalyticsEvent.swift b/WordPress/Classes/Utility/Analytics/WPAnalyticsEvent.swift index 6d11cf031fe0..7ffc738c2003 100644 --- a/WordPress/Classes/Utility/Analytics/WPAnalyticsEvent.swift +++ b/WordPress/Classes/Utility/Analytics/WPAnalyticsEvent.swift @@ -115,6 +115,7 @@ import Foundation case readerArticleTextCopied case readerCommentTextHighlighted case readerCommentTextCopied + case readerPostContextMenuButtonTapped // Stats - Empty Stats nudges case statsPublicizeNudgeShown @@ -834,6 +835,8 @@ import Foundation return "reader_comment_text_highlighted" case .readerCommentTextCopied: return "reader_comment_text_copied" + case .readerPostContextMenuButtonTapped: + return "reader_post_context_menu_button_tapped" // Stats - Empty Stats nudges case .statsPublicizeNudgeShown: diff --git a/WordPress/Classes/Utility/Media/FaviconService.swift b/WordPress/Classes/Utility/Media/FaviconService.swift new file mode 100644 index 000000000000..08dc2b89fdb9 --- /dev/null +++ b/WordPress/Classes/Utility/Media/FaviconService.swift @@ -0,0 +1,111 @@ +import UIKit + +// Fetches URLs for favicons for sites. +actor FaviconService { + static let shared = FaviconService() + + private nonisolated let cache = FaviconCache() + + private let session = URLSession(configuration: { + let configuration = URLSessionConfiguration.default + configuration.urlCache = nil + return configuration + }()) + + private var tasks: [URL: FaviconTask] = [:] + + nonisolated func cachedFavicon(forURL siteURL: URL) -> URL? { + cache.cachedFavicon(forURL: siteURL) + } + + /// Returns a favicon URL for the given site. + func favicon(forURL siteURL: URL) async throws -> URL { + if let faviconURL = cache.cachedFavicon(forURL: siteURL) { + return faviconURL + } + let faviconURL = try await _favicon(forURL: siteURL) + cache.storeCachedFaviconURL(faviconURL, forURL: siteURL) + return faviconURL + } + + private func _favicon(forURL siteURL: URL) async throws -> URL { + let task = tasks[siteURL] ?? FaviconTask { [session] in + let (data, response) = try await session.data(from: siteURL) + try validate(response: response) + return await makeFavicon(from: data, siteURL: siteURL) + } + let subscriptionID = UUID() + task.subscriptions.insert(subscriptionID) + tasks[siteURL] = task + return try await withTaskCancellationHandler { + try await task.task.value + } onCancel: { + Task { + await self.unsubscribe(subscriptionID, key: siteURL) + } + } + } + + private func unsubscribe(_ subscriptionID: UUID, key: URL) { + guard let task = tasks[key], + task.subscriptions.remove(subscriptionID) != nil, + task.subscriptions.isEmpty else { + return + } + task.task.cancel() + tasks[key] = nil + } +} + +enum FaviconError: Error { + case unacceptableStatusCode(_ code: Int) +} + +private final class FaviconCache: @unchecked Sendable { + private let cache = NSCache() + + func cachedFavicon(forURL siteURL: URL) -> URL? { + cache.object(forKey: siteURL as NSURL) as? URL + } + + func storeCachedFaviconURL(_ faviconURL: URL, forURL siteURL: URL) { + cache.setObject(faviconURL as NSURL, forKey: siteURL as NSURL) + } +} + +private let regex: NSRegularExpression? = { + let pattern = "]*rel=\"apple-touch-icon\"[^>]*href=\"([^\"]+)\"[^>]*>" + return try? NSRegularExpression(pattern: pattern, options: .caseInsensitive) +}() + +private func makeFavicon(from data: Data, siteURL: URL) async -> URL { + let html = String(data: data, encoding: .utf8) ?? "" + let range = NSRange(location: 0, length: html.utf16.count) + if let match = regex?.firstMatch(in: html, options: [], range: range), + let matchRange = Range(match.range(at: 1), in: html), + let faviconURL = URL(string: String(html[matchRange]), relativeTo: siteURL) { + return faviconURL + } + // Fallback to standard favicon path. It has low quality, but + // it's better than nothing. + return siteURL.appendingPathComponent("favicon.icon") +} + +private func validate(response: URLResponse) throws { + guard let response = response as? HTTPURLResponse else { + return + } + guard (200..<300).contains(response.statusCode) else { + throw FaviconError.unacceptableStatusCode(response.statusCode) + } +} + +private final class FaviconTask { + var subscriptions = Set() + var isCancelled = false + var task: Task + + init(_ closure: @escaping () async throws -> URL) { + self.task = Task { try await closure() } + } +} diff --git a/WordPress/Classes/Utility/Media/ImageView.swift b/WordPress/Classes/Utility/Media/ImageView.swift index 7cb2a2a27d3f..c1286f88b649 100644 --- a/WordPress/Classes/Utility/Media/ImageView.swift +++ b/WordPress/Classes/Utility/Media/ImageView.swift @@ -16,6 +16,7 @@ final class ImageView: UIView { case spinner } + var isErrorViewEnabled = true var loadingStyle = LoadingStyle.background override init(frame: CGRect) { @@ -70,7 +71,9 @@ final class ImageView: UIView { imageView.isHidden = false backgroundColor = .clear case .failure: - makeErrorView().isHidden = false + if isErrorViewEnabled { + makeErrorView().isHidden = false + } } } diff --git a/WordPress/Classes/ViewRelated/Reader/ReaderCardsStreamViewController.swift b/WordPress/Classes/ViewRelated/Reader/ReaderCardsStreamViewController.swift index 3fa43ca9882f..c1329c91f427 100644 --- a/WordPress/Classes/ViewRelated/Reader/ReaderCardsStreamViewController.swift +++ b/WordPress/Classes/ViewRelated/Reader/ReaderCardsStreamViewController.swift @@ -97,6 +97,7 @@ class ReaderCardsStreamViewController: ReaderStreamViewController { let cell = tableView.dequeueReusableCell(withIdentifier: readerCardTopicsIdentifier) as! ReaderTopicsCardCell cell.configure(interests) cell.delegate = self + hideSeparator(for: cell) return cell } @@ -104,6 +105,7 @@ class ReaderCardsStreamViewController: ReaderStreamViewController { let cell = tableView.dequeueReusableCell(withIdentifier: readerCardSitesIdentifier) as! ReaderSitesCardCell cell.configure(sites) cell.delegate = self + hideSeparator(for: cell) return cell } diff --git a/WordPress/Classes/ViewRelated/Reader/ReaderPostCell.swift b/WordPress/Classes/ViewRelated/Reader/ReaderPostCell.swift new file mode 100644 index 000000000000..20f42ad93882 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Reader/ReaderPostCell.swift @@ -0,0 +1,440 @@ +import SwiftUI +import UIKit +import Combine + +final class ReaderPostCell: UITableViewCell { + private let view = ReaderPostCellView() + private var isSeparatorHidden = false + private var isCompact: Bool = true { + didSet { + guard oldValue != isCompact else { return } + setNeedsUpdateConstraints() + } + } + private var contentViewConstraints: [NSLayoutConstraint] = [] + + static let avatarSize: CGFloat = 28 + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + + contentView.addSubview(view) + view.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + view.topAnchor.constraint(equalTo: contentView.topAnchor), + view.bottomAnchor.constraint(equalTo: contentView.bottomAnchor).withPriority(999), + ]) + + selectedBackgroundView = UIView() + selectedBackgroundView?.backgroundColor = UIColor.opaqueSeparator.withAlphaComponent(0.2) + } + + required init?(coder: NSCoder) { + fatalError("Not implemented") + } + + override func prepareForReuse() { + super.prepareForReuse() + + view.prepareForReuse() + } + + func configure( + with viewModel: ReaderPostCellViewModel, + isCompact: Bool, + isSeparatorHidden: Bool + ) { + self.isSeparatorHidden = isSeparatorHidden + self.isCompact = isCompact + + view.isCompact = isCompact + updateSeparatorsInsets() + view.configure(with: viewModel) + + accessibilityLabel = "\(viewModel.author). \(viewModel.title). \(viewModel.details)" + } + + override func layoutSubviews() { + super.layoutSubviews() + + updateSeparatorsInsets() + } + + private func updateSeparatorsInsets() { + separatorInset = UIEdgeInsets(.leading, isSeparatorHidden ? 9999 : view.insets.left + contentView.readableContentGuide.layoutFrame.minX) + } + + override func updateConstraints() { + NSLayoutConstraint.deactivate(contentViewConstraints) + contentViewConstraints = [ + view.leadingAnchor.constraint(equalTo: isCompact ? contentView.leadingAnchor : contentView.readableContentGuide.leadingAnchor), + view.trailingAnchor.constraint(equalTo: isCompact ? contentView.trailingAnchor : contentView.readableContentGuide.trailingAnchor) + ] + NSLayoutConstraint.activate(contentViewConstraints) + + super.updateConstraints() + } +} + +private final class ReaderPostCellView: UIView { + // Header + let avatarView = ImageView() + let buttonAuthor = makeAuthorButton() + let timeLabel = UILabel() + let buttonMore = makeButton(systemImage: "ellipsis", font: .systemFont(ofSize: 13)) + + // Content + let titleLabel = UILabel() + let detailsLabel = UILabel() + let imageView = ImageView() + + // Footer + let buttons = ReaderPostToolbarButtons() + + private lazy var postPreview = UIStackView(axis: .vertical, alignment: .leading, spacing: 12, [ + UIStackView(axis: .vertical, spacing: 4, [titleLabel, detailsLabel]), + imageView + ]) + + var isCompact: Bool = true { + didSet { + guard oldValue != isCompact else { return } + configureLayout(isCompact: isCompact) + } + } + + let insets = UIEdgeInsets(top: 0, left: 44, bottom: 0, right: 16) + + private var viewModel: ReaderPostCellViewModel? // important: has to retain + private let coverAspectRatio: CGFloat = 239.0 / 358.0 + private var imageViewConstraints: [NSLayoutConstraint] = [] + private var cancellables: [AnyCancellable] = [] + + override init(frame: CGRect) { + super.init(frame: frame) + + setupStyle() + setupLayout() + setupActions() + setupAccessibility() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func prepareForReuse() { + cancellables = [] + avatarView.prepareForReuse() + imageView.prepareForReuse() + } + + private func setupStyle() { + avatarView.layer.cornerRadius = ReaderPostCell.avatarSize / 2 + avatarView.layer.masksToBounds = true + avatarView.isErrorViewEnabled = false + + buttonAuthor.maximumContentSizeCategory = .accessibilityLarge + setupTimeLabel(timeLabel) + timeLabel.setContentCompressionResistancePriority(.init(800), for: .horizontal) + + titleLabel.font = .preferredFont(forTextStyle: .headline) + titleLabel.adjustsFontForContentSizeCategory = true + titleLabel.maximumContentSizeCategory = .accessibilityExtraLarge + + detailsLabel.font = .preferredFont(forTextStyle: .subheadline) + detailsLabel.textColor = .secondaryLabel + detailsLabel.adjustsFontForContentSizeCategory = true + detailsLabel.maximumContentSizeCategory = .accessibilityExtraLarge + + imageView.layer.cornerRadius = 8 + imageView.layer.masksToBounds = true + imageView.contentMode = .scaleAspectFill + + buttonMore.configuration?.baseForegroundColor = UIColor.opaqueSeparator + buttonMore.configuration?.contentInsets = .init(top: 12, leading: 8, bottom: 12, trailing: 20) + } + + private func setupLayout() { + let dot = UILabel() + setupTimeLabel(dot) + dot.text = " · " + + // These seems to be an issue with `lineBreakMode` in `UIButton.Configuration` + // and `.firstLineBaseline`, so reserving to `.center`. + let headerView = UIStackView(alignment: .center, [buttonAuthor, dot, timeLabel]) + let toolbarView = UIStackView(buttons.allButtons) + + for view in [avatarView, headerView, postPreview, buttonMore, toolbarView] { + addSubview(view) + view.translatesAutoresizingMaskIntoConstraints = false + } + + // Using constraints as it provides a bit more control, performance, and + // is arguable more readable than too many nested stacks. + NSLayoutConstraint.activate([ + avatarView.widthAnchor.constraint(equalToConstant: ReaderPostCell.avatarSize), + avatarView.heightAnchor.constraint(equalToConstant: ReaderPostCell.avatarSize), + avatarView.centerYAnchor.constraint(equalTo: timeLabel.centerYAnchor), + avatarView.trailingAnchor.constraint(equalTo: headerView.leadingAnchor, constant: -8), + + headerView.topAnchor.constraint(equalTo: topAnchor, constant: 4), + headerView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: insets.left), + headerView.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor, constant: -50), + + buttonMore.centerYAnchor.constraint(equalTo: headerView.centerYAnchor), + buttonMore.trailingAnchor.constraint(equalTo: trailingAnchor), + + postPreview.topAnchor.constraint(equalTo: headerView.bottomAnchor, constant: 0), + postPreview.leadingAnchor.constraint(equalTo: leadingAnchor, constant: insets.left), + postPreview.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -insets.right), + postPreview.bottomAnchor.constraint(equalTo: toolbarView.topAnchor), + + // Align with a preview, but keep the extended frame to make it easier to tap + toolbarView.leadingAnchor.constraint(equalTo: postPreview.leadingAnchor, constant: -14), + toolbarView.bottomAnchor.constraint(equalTo: bottomAnchor) + ]) + + configureLayout(isCompact: isCompact) + } + + private func setupTimeLabel(_ label: UILabel) { + label.font = .preferredFont(forTextStyle: .footnote) + label.textColor = .secondaryLabel + label.adjustsFontForContentSizeCategory = true + label.maximumContentSizeCategory = .accessibilityMedium + } + + private func configureLayout(isCompact: Bool) { + titleLabel.numberOfLines = 2 + detailsLabel.numberOfLines = isCompact ? 3 : 5 + + postPreview.axis = isCompact ? .vertical : .horizontal + postPreview.spacing = isCompact ? 12 : 20 + + setNeedsUpdateConstraints() + } + + override func updateConstraints() { + NSLayoutConstraint.deactivate(imageViewConstraints) + if isCompact { + imageViewConstraints = [ + imageView.heightAnchor.constraint(equalTo: imageView.widthAnchor, multiplier: coverAspectRatio), + imageView.widthAnchor.constraint(lessThanOrEqualTo: widthAnchor, constant: -(insets.left * 2)), + imageView.widthAnchor.constraint(equalTo: widthAnchor).withPriority(150) + ] + } else { + imageViewConstraints = [ + imageView.heightAnchor.constraint(equalTo: imageView.widthAnchor, multiplier: coverAspectRatio), + imageView.widthAnchor.constraint(equalToConstant: 200) + ] + } + NSLayoutConstraint.activate(imageViewConstraints) + + super.updateConstraints() + } + + // MARK: Actions + + private func setupActions() { + buttonAuthor.addTarget(self, action: #selector(buttonAuthorTapped), for: .primaryActionTriggered) + buttonMore.showsMenuAsPrimaryAction = true + buttonMore.menu = UIMenu(options: .displayInline, children: [ + UIDeferredMenuElement.uncached { [weak self] callback in + callback(self?.makeMoreMenu() ?? []) + } + ]) + buttons.bookmark.addTarget(self, action: #selector(buttonBookmarkTapped), for: .primaryActionTriggered) + buttons.reblog.addTarget(self, action: #selector(buttonReblogTapped), for: .primaryActionTriggered) + buttons.comment.addTarget(self, action: #selector(buttonCommentTapped), for: .primaryActionTriggered) + buttons.like.addTarget(self, action: #selector(buttonLikeTapped), for: .primaryActionTriggered) + } + + @objc private func buttonAuthorTapped() { + viewModel?.showSiteDetails() + } + + @objc private func buttonBookmarkTapped() { + viewModel?.toogleBookmark() + } + + @objc private func buttonReblogTapped() { + viewModel?.reblog() + } + + @objc private func buttonCommentTapped() { + viewModel?.comment() + } + + @objc private func buttonLikeTapped() { + viewModel?.toggleLike() + } + + private func makeMoreMenu() -> [UIMenuElement] { + guard let viewModel, let viewController = viewModel.viewController else { + return [] + } + return ReaderPostMenu( + post: viewModel.post, + topic: viewController.readerTopic, + button: buttonMore, + viewController: viewController + ).makeMenu() + } + + // MARK: Configure (ViewModel) + + func configure(with viewModel: ReaderPostCellViewModel) { + self.viewModel = viewModel + + setAvatar(with: viewModel) + buttonAuthor.configuration?.attributedTitle = AttributedString(viewModel.author, attributes: Self.authorAttributes) + timeLabel.text = viewModel.time + + titleLabel.text = viewModel.title + detailsLabel.text = viewModel.details + + imageView.isHidden = viewModel.imageURL == nil + if let imageURL = viewModel.imageURL { + imageView.setImage(with: imageURL) + } + + buttons.bookmark.configuration = { + var configuration = buttons.bookmark.configuration ?? .plain() + configuration.image = UIImage(systemName: viewModel.isBookmarked ? "bookmark.fill" : "bookmark") + configuration.baseForegroundColor = viewModel.isBookmarked ? UIAppColor.brand : .secondaryLabel + return configuration + }() + + buttons.comment.isHidden = !viewModel.isCommentsEnabled + if viewModel.isCommentsEnabled { + buttons.comment.configuration?.attributedTitle = AttributedString(kFormatted(viewModel.commentCount), attributes: Self.toolbarAttributes) + } + buttons.like.isHidden = !viewModel.isLikesEnabled + if viewModel.isLikesEnabled { + buttons.like.configuration = { + var configuration = buttons.like.configuration ?? .plain() + configuration.attributedTitle = AttributedString(kFormatted(viewModel.likeCount), attributes: Self.toolbarAttributes) + configuration.image = UIImage(systemName: viewModel.isLiked ? "star.fill" : "star") + configuration.baseForegroundColor = viewModel.isLiked ? .systemYellow : .secondaryLabel + return configuration + }() + } + + configureAccessibility(with: viewModel) + } + + private func setAvatar(with viewModel: ReaderPostCellViewModel) { + avatarView.imageView.image = UIImage(named: "post-blavatar-placeholder") + let avatarSize = CGSize(width: ReaderPostCell.avatarSize, height: ReaderPostCell.avatarSize) + .scaled(by: UITraitCollection.current.displayScale) + if let avatarURL = viewModel.avatarURL { + avatarView.setImage(with: avatarURL, size: avatarSize) + } else { + viewModel.$avatarURL.compactMap({ $0 }).sink { [weak self] in + self?.avatarView.setImage(with: $0, size: avatarSize) + }.store(in: &cancellables) + } + } + + private static let authorAttributes = AttributeContainer([ + .font: WPStyleGuide.fontForTextStyle(.footnote, fontWeight: .medium), + .foregroundColor: UIColor.label + ]) + + private static let toolbarAttributes = AttributeContainer([ + .font: UIFont.preferredFont(forTextStyle: .footnote), + .foregroundColor: UIColor.secondaryLabel + ]) +} + +// MARK: - Helpers + +private struct ReaderPostToolbarButtons { + let bookmark = makeButton(systemImage: "bookmark") + let reblog = makeButton(systemImage: "arrow.2.squarepath") + let comment = makeButton(systemImage: "message") + let like = makeButton(systemImage: "star") + + var allButtons: [UIButton] { [bookmark, reblog, comment, like] } +} + +private func makeAuthorButton() -> UIButton { + var configuration = UIButton.Configuration.plain() + configuration.titleLineBreakMode = .byTruncatingTail + configuration.contentInsets = .init(top: 8, leading: 0, bottom: 8, trailing: 0) + return UIButton(configuration: configuration) +} + +private func makeButton(systemImage: String, font: UIFont = UIFont.preferredFont(forTextStyle: .footnote)) -> UIButton { + var configuration = UIButton.Configuration.plain() + configuration.image = UIImage(systemName: systemImage) + configuration.imagePadding = 8 + configuration.preferredSymbolConfigurationForImage = UIImage.SymbolConfiguration(font: font) + configuration.baseForegroundColor = .secondaryLabel + configuration.contentInsets = .init(top: 16, leading: 12, bottom: 14, trailing: 12) + + let button = UIButton(configuration: configuration) + if #available(iOS 17.0, *) { + button.isSymbolAnimationEnabled = true + } + button.maximumContentSizeCategory = .extraExtraExtraLarge + return button +} + +private func kFormatted(_ count: Int) -> String { + count.formatted(.number.notation(.compactName)) +} + +// MARK: - ReaderPostCellView (Accessibility) + +private extension ReaderPostCellView { + func setupAccessibility() { + buttonAuthor.accessibilityHint = NSLocalizedString("reader.post.buttonSite.accessibilityHint", value: "Opens the site details", comment: "Accessibility hint for the site header") + buttonMore.accessibilityLabel = NSLocalizedString("reader.post.moreMenu.accessibilityLabel", value: "More actions", comment: "Button accessibility label") + + buttonAuthor.accessibilityIdentifier = "reader-author-button" + buttonMore.accessibilityIdentifier = "reader-more-button" + buttons.bookmark.accessibilityIdentifier = "reader-bookmark-button" + buttons.reblog.accessibilityIdentifier = "reader-reblog-button" + buttons.comment.accessibilityIdentifier = "reader-comment-button" + buttons.like.accessibilityIdentifier = "reader-like-button" + } + + func configureAccessibility(with viewModel: ReaderPostCellViewModel) { + buttons.bookmark.accessibilityLabel = viewModel.isBookmarked ? NSLocalizedString("reader.post.buttonRemoveBookmark.accessibilityLint", value: "Remove bookmark", comment: "Button accessibility label") : NSLocalizedString("reader.post.buttonBookmark.accessibilityLabel", value: "Bookmark", comment: "Button accessibility label") + buttons.reblog.accessibilityLabel = NSLocalizedString("reader.post.buttonReblog.accessibilityLabel", value: "Reblog", comment: "Button accessibility label") + buttons.comment.accessibilityLabel = { + let label = NSLocalizedString("reader.post.buttonComment.accessibilityLabel", value: "Show comments", comment: "Button accessibility label") + let count = String(format: NSLocalizedString("reader.post.numberOfComments.accessibilityLabel", value: "%@ comments", comment: "Accessibility label showing total number of comments"), viewModel.commentCount.description) + return "\(label). \(count)." + }() + buttons.like.accessibilityLabel = { + let label = viewModel.isLiked ? NSLocalizedString("reader.post.buttonRemoveLike.accessibilityLabel", value: "Remove like", comment: "Button accessibility label") : NSLocalizedString("reader.post.buttonLike.accessibilityLabel", value: "Like", comment: "Button accessibility label") + let count = String(format: NSLocalizedString("reader.post.numberOfLikes.accessibilityLabel", value: "%@ likes", comment: "Accessibility label showing total number of likes"), viewModel.likeCount.description) + return "\(label). \(count)." + }() + } +} + +// MARK: - Previews + +@available(iOS 17, *) +#Preview { + let cell = ReaderPostCellView() + cell.configure(with: .mock()) + cell.isCompact = true + + let vc = UIViewController() + vc.view.addSubview(cell) + cell.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + cell.leadingAnchor.constraint(equalTo: vc.view.leadingAnchor, constant: cell.isCompact ? 0 : 128), + cell.trailingAnchor.constraint(equalTo: vc.view.trailingAnchor, constant: cell.isCompact ? 0 : -128), + cell.centerYAnchor.constraint(equalTo: vc.view.centerYAnchor) + ]) + cell.layer.borderColor = UIColor.separator.cgColor + cell.layer.borderWidth = 0.5 + + return vc +} diff --git a/WordPress/Classes/ViewRelated/Reader/ReaderPostCellViewModel.swift b/WordPress/Classes/ViewRelated/Reader/ReaderPostCellViewModel.swift new file mode 100644 index 000000000000..8b952dc6f453 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Reader/ReaderPostCellViewModel.swift @@ -0,0 +1,115 @@ +import Foundation + +final class ReaderPostCellViewModel { + // Header + @Published private(set) var avatarURL: URL? + let author: String + let time: String + + // Content + let title: String + let details: String + let imageURL: URL? + + // Footer (Buttons) + let isBookmarked: Bool + let isCommentsEnabled: Bool + let commentCount: Int + let isLikesEnabled: Bool + let likeCount: Int + let isLiked: Bool + + weak var viewController: ReaderStreamViewController? + + let post: ReaderPost + private var faviconTask: Task? + + deinit { + faviconTask?.cancel() + } + + init(post: ReaderPost, topic: ReaderAbstractTopic?) { + self.post = post + + let isP2 = (topic as? ReaderSiteTopic)?.isP2Type == true + + if isP2 { + self.author = post.authorDisplayName ?? "" + } else { + self.author = post.blogNameForDisplay() ?? "" + } + self.time = post.dateForDisplay()?.toShortString() ?? "–" + self.title = post.titleForDisplay() ?? "" + self.details = post.contentPreviewForDisplay() ?? "" + self.imageURL = post.featuredImageURLForDisplay() + + self.isBookmarked = post.isSavedForLater + + self.isCommentsEnabled = post.isCommentsEnabled + self.commentCount = post.commentCount?.intValue ?? 0 + + self.isLikesEnabled = post.isLikesEnabled() + self.likeCount = post.likeCount?.intValue ?? 0 + self.isLiked = post.isLiked() + + if isP2 { + self.avatarURL = post.authorAvatarURL.flatMap(URL.init) + } else if let avatarURL = post.siteIconForDisplay(ofSize: Int(ReaderPostCell.avatarSize)) { + self.avatarURL = avatarURL + } else if let blogURL = post.blogURL.flatMap(URL.init) { + if let faviconURL = FaviconService.shared.cachedFavicon(forURL: blogURL) { + self.avatarURL = faviconURL + } else { + faviconTask = Task { @MainActor [weak self] in + self?.avatarURL = try? await FaviconService.shared.favicon(forURL: blogURL) + } + } + } + } + + private init() { + self.post = ReaderPost.init(entity: NSEntityDescription.entity(forEntityName: ReaderPost.entityName(), in: ContextManager.shared.mainContext)!, insertInto: nil) + self.avatarURL = URL(string: "https://picsum.photos/120/120.jpg") + self.author = "WordPress Mobile Apps" + self.time = "9d ago" + self.title = "Discovering the Wonders of the Wild" + self.details = "Lorem ipsum dolor sit amet. Non omnis quia et natus voluptatum et eligendi voluptate vel iusto fuga sit repellendus molestiae aut voluptatem blanditiis ad neque sapiente. Id galisum distinctio quo enim aperiam non veritatis vitae et ducimus rerum." + self.imageURL = URL(string: "https://picsum.photos/1260/630.jpg") + self.isBookmarked = false + self.isLikesEnabled = true + self.likeCount = 9000 + self.isCommentsEnabled = true + self.commentCount = 213 + self.isLiked = true + } + + static func mock() -> ReaderPostCellViewModel { + ReaderPostCellViewModel() + } + + // MARK: Actions + + func showSiteDetails() { + guard let viewController else { return } + ReaderHeaderAction().execute(post: post, origin: viewController) + } + + func toogleBookmark() { + guard let viewController else { return } + ReaderSaveForLaterAction().execute(with: post, origin: .otherStream, viewController: viewController) + } + + func reblog() { + guard let viewController else { return } + ReaderReblogAction().execute(readerPost: post, origin: viewController, reblogSource: .list) + } + + func comment() { + guard let viewController else { return } + ReaderCommentAction().execute(post: post, origin: viewController, source: .postCard) + } + + func toggleLike() { + ReaderLikeAction().execute(with: post) + } +} diff --git a/WordPress/Classes/ViewRelated/Reader/ReaderPostMenu.swift b/WordPress/Classes/ViewRelated/Reader/ReaderPostMenu.swift new file mode 100644 index 000000000000..5805cac25673 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Reader/ReaderPostMenu.swift @@ -0,0 +1,260 @@ +import Foundation +import UIKit +import SafariServices + +struct ReaderPostMenu { + let post: ReaderPost + let topic: ReaderAbstractTopic? + weak var button: UIButton? + weak var viewController: UIViewController? + var context = ContextManager.shared.mainContext + + func makeMenu() -> [UIMenuElement] { + return [ + makePrimaryActions(), + makeSecondaryActions(), + shouldShowReportOrBlockMenu ? makeBlockOrReportActions() : nil + ].compactMap { $0 } + } + + private func makePrimaryActions() -> UIMenu { + let menu = UIMenu(options: [.displayInline], children: [ + share, comment, like, bookmark, reblog + ].compactMap { $0 }) + menu.preferredElementSize = .medium + return menu + } + + private func makeSecondaryActions() -> UIMenu { + UIMenu(options: [.displayInline], children: [ + viewPostInBrowser, + copyPostLink, + makeBlogMenu(), + ].compactMap { $0 }) + } + + private func makeBlogMenu() -> UIMenuElement { + var actions: [UIAction] = [goToBlog] + if let siteURL = post.blogURL.flatMap(URL.init) { + actions.append(viewBlogInBrowser(siteURL: siteURL)) + } + if post.isFollowing { + if let siteID = post.siteID?.intValue { + actions.append(manageNotifications(for: siteID)) + } + actions += [ubsubscribe] + } else { + actions += [subscribe] + } + return UIMenu(title: post.blogNameForDisplay() ?? Strings.blogDetails, children: actions) + } + + // MARK: Actions + + private var share: UIAction { + UIAction(Strings.share, systemImage: "square.and.arrow.up") { + guard let viewController else { return } + ReaderShareAction().execute(with: post, context: context, anchor: button ?? viewController.view, vc: viewController) + track(.share) + } + } + + private var bookmark: UIAction { + let isBookmarked = post.isSavedForLater + return UIAction(isBookmarked ? Strings.bookmarked : Strings.bookmark, systemImage: isBookmarked ? "bookmark.fill" : "bookmark") { + guard let viewController else { return } + ReaderSaveForLaterAction().execute(with: post, origin: .otherStream, viewController: viewController) + track(isBookmarked ? .removeBookmark : .bookmark) + } + } + + private var reblog: UIAction { + UIAction(Strings.reblog, systemImage: "arrow.2.squarepath") { + guard let viewController else { return } + ReaderSaveForLaterAction().execute(with: post, origin: .otherStream, viewController: viewController) + track(.reblog) + } + } + + private var comment: UIAction? { + guard post.isCommentsEnabled else { return nil } + return UIAction(Strings.comment, systemImage: "message") { + guard let viewController else { return } + ReaderCommentAction().execute(post: post, origin: viewController, source: .postCard) + track(.comment) + } + } + + private var like: UIAction? { + guard post.isLikesEnabled else { return nil } + let isLiked = post.isLiked + return UIAction(isLiked ? Strings.liked : Strings.like, systemImage: isLiked ? "star.fill" : "star") { + ReaderLikeAction().execute(with: post) + track(isLiked ? .removeLike : .like) + } + } + + private var viewPostInBrowser: UIAction? { + guard let postURL = post.permaLink.flatMap(URL.init) else { return nil } + return UIAction(Strings.viewInBrowser, systemImage: "safari") { + let safariVC = SFSafariViewController(url: postURL) + viewController?.present(safariVC, animated: true) + track(.viewPostInBrowser) + } + } + + private var copyPostLink: UIAction? { + guard let postURL = post.permaLink.flatMap(URL.init) else { return nil } + return UIAction(Strings.copyLink, systemImage: "link") { + UIPasteboard.general.string = postURL.absoluteString + UINotificationFeedbackGenerator().notificationOccurred(.success) + track(.copyPostLink) + } + } + + private var goToBlog: UIAction { + UIAction(Strings.goToBlog, systemImage: "chevron.right") { + guard let viewController else { return } + ReaderHeaderAction().execute(post: post, origin: viewController) + track(.goToBlog) + } + } + + private var subscribe: UIAction { + UIAction(Strings.subscribe, systemImage: "plus.circle") { + ReaderSubscriptionHelper().toggleSiteSubscription(forPost: post) + track(.subscribe) + } + } + + private func viewBlogInBrowser(siteURL: URL) -> UIAction { + return UIAction(Strings.viewInBrowser, systemImage: "safari") { + let safariVC = SFSafariViewController(url: siteURL) + viewController?.present(safariVC, animated: true) + track(.viewBlogInBrowser) + } + } + + private var ubsubscribe: UIAction { + UIAction(Strings.unsubscribe, systemImage: "minus.circle", attributes: [.destructive]) { + ReaderSubscriptionHelper().toggleSiteSubscription(forPost: post) + track(.unsubscribe) + } + } + + private func manageNotifications(for siteID: Int) -> UIAction { + UIAction(Strings.manageNotifications, systemImage: "bell") { + guard let viewController else { return } + NotificationSiteSubscriptionViewController.show(forSiteID: siteID, sourceItem: button ?? viewController.view, from: viewController) + track(.manageNotifications) + } + } + + // MARK: Block and Report + + private func makeBlockOrReportActions() -> UIMenu { + UIMenu(title: Strings.blockOrReport, image: UIImage(systemName: "hand.raised"), options: [.destructive], children: [ + blockSite, + post.isWPCom ? blockUser : nil, + reportPost, + reportUser, + ].compactMap { $0 }) + } + + private var blockSite: UIAction { + UIAction(Strings.blockSite, systemImage: "hand.raised", attributes: [.destructive]) { + ReaderBlockingHelper().blockSite(forPost: post) + track(.blockSite) + } + } + + private var blockUser: UIAction { + UIAction(Strings.blockUser, systemImage: "hand.raised", attributes: [.destructive]) { + ReaderBlockingHelper().blockUser(forPost: post) + track(.blockUser) + } + } + + private var reportPost: UIAction { + UIAction(Strings.reportPost, systemImage: "flag", attributes: [.destructive]) { + guard let viewController else { return } + ReaderReportPostAction().execute(with: post, context: context, origin: viewController) + track(.reportPost) + } + } + + private var reportUser: UIAction { + UIAction(Strings.reportUser, systemImage: "flag", attributes: [.destructive]) { + guard let viewController else { return } + ReaderReportPostAction().execute(with: post, target: .author, context: context, origin: viewController) + track(.reportUser) + } + } + + private var shouldShowReportOrBlockMenu: Bool { + guard let topic else { + return false + } + return ReaderHelpers.isTopicTag(topic) || + ReaderHelpers.topicIsDiscover(topic) || + ReaderHelpers.topicIsFreshlyPressed(topic) || + ReaderHelpers.topicIsFollowing(topic) + } + + // MARK: Helpers + + private func track(_ button: ReaderPostMenuAnalyticsButton) { + WPAnalytics.track(.readerPostContextMenuButtonTapped, properties: [ + "button": button.rawValue + ]) + } +} + +private extension UIAction { + convenience init(_ title: String, systemImage: String, attributes: UIMenuElement.Attributes = [], _ action: @escaping () -> Void) { + self.init(title: title, image: UIImage(systemName: systemImage), attributes: attributes, handler: { _ in action() }) + } +} + +private enum ReaderPostMenuAnalyticsButton: String { + case share = "share" + case bookmark = "bookmark" + case removeBookmark = "remove_bookmark" + case like = "like" + case removeLike = "remove_like" + case comment = "comment" + case reblog = "reblog" + case viewPostInBrowser = "view_in_browser" + case copyPostLink = "copy_post_link" + case goToBlog = "blog_open" + case viewBlogInBrowser = "blog_view_in_browser" + case subscribe = "blog_subscribe" + case unsubscribe = "blog_unsubscribe" + case manageNotifications = "blog_manage_notifications" + case blockSite = "block_site" + case blockUser = "block_user" + case reportPost = "report_post" + case reportUser = "report_user" +} + +private enum Strings { + static let share = NSLocalizedString("reader.postContextMenu.share", value: "Share", comment: "Context menu action") + static let bookmark = NSLocalizedString("reader.postContextMenu.bookmark", value: "Bookmark", comment: "Context menu action") + static let bookmarked = NSLocalizedString("reader.postContextMenu.bookmarked", value: "Bookmarked", comment: "Context menu action") + static let reblog = NSLocalizedString("reader.postContextMenu.reblog", value: "Reblog", comment: "Context menu action") + static let comment = NSLocalizedString("reader.postContextMenu.comment", value: "Comment", comment: "Context menu action") + static let like = NSLocalizedString("reader.postContextMenu.like", value: "Like", comment: "Context menu action") + static let liked = NSLocalizedString("reader.postContextMenu.liked", value: "Liked", comment: "Context menu action") + static let viewInBrowser = NSLocalizedString("reader.postContextMenu.viewInBrowser", value: "View in Browser", comment: "Context menu action") + static let copyLink = NSLocalizedString("reader.postContextMenu.copyLink", value: "Copy Link", comment: "Context menu action") + static let blockOrReport = NSLocalizedString("reader.postContextMenu.blockOrReportMenu", value: "Block or Report", comment: "Context menu action") + static let goToBlog = NSLocalizedString("reader.postContextMenu.showBlog", value: "Go to Blog", comment: "Context menu action") + static let subscribe = NSLocalizedString("reader.postContextMenu.subscribeT", value: "Subscribe", comment: "Context menu action") + static let unsubscribe = NSLocalizedString("reader.postContextMenu.unsubscribe", value: "Unsubscribe", comment: "Context menu action") + static let manageNotifications = NSLocalizedString("reader.postContextMenu.manageNotifications", value: "Manage Notifications", comment: "Context menu action") + static let blogDetails = NSLocalizedString("reader.postContextMenu.blogDetails", value: "Blog Details", comment: "Context menu action (placeholder value when blog name not available – should never happen)") + static let blockSite = NSLocalizedString("reader.postContextMenu.blockSite", value: "Block Site", comment: "Context menu action") + static let blockUser = NSLocalizedString("reader.postContextMenu.blockUser", value: "Block User", comment: "Context menu action") + static let reportPost = NSLocalizedString("reader.postContextMenu.reportPost", value: "Report Post", comment: "Context menu action") + static let reportUser = NSLocalizedString("reader.postContextMenu.reportUser", value: "Report User", comment: "Context menu action") +} diff --git a/WordPress/Classes/ViewRelated/Reader/ReaderSaveForLaterAction.swift b/WordPress/Classes/ViewRelated/Reader/ReaderSaveForLaterAction.swift index 4f9c579b940c..351e5d274f06 100644 --- a/WordPress/Classes/ViewRelated/Reader/ReaderSaveForLaterAction.swift +++ b/WordPress/Classes/ViewRelated/Reader/ReaderSaveForLaterAction.swift @@ -15,7 +15,7 @@ final class ReaderSaveForLaterAction { self.visibleConfirmation = visibleConfirmation } - func execute(with post: ReaderPost, context: NSManagedObjectContext, origin: ReaderSaveForLaterOrigin, viewController: UIViewController?, completion: (() -> Void)? = nil) { + func execute(with post: ReaderPost, context: NSManagedObjectContext = ContextManager.shared.mainContext, origin: ReaderSaveForLaterOrigin, viewController: UIViewController?, completion: (() -> Void)? = nil) { /// Preload the post if let viewController = viewController, !post.isSavedForLater { let offlineReaderWebView = OfflineReaderWebView() diff --git a/WordPress/Classes/ViewRelated/Reader/ReaderStreamViewController.swift b/WordPress/Classes/ViewRelated/Reader/ReaderStreamViewController.swift index 52366a803b9b..72c0b6221722 100644 --- a/WordPress/Classes/ViewRelated/Reader/ReaderStreamViewController.swift +++ b/WordPress/Classes/ViewRelated/Reader/ReaderStreamViewController.swift @@ -1494,38 +1494,50 @@ extension ReaderStreamViewController: WPTableViewHandlerDelegate { if post.isKind(of: ReaderGapMarker.self) { let cell = tableConfiguration.gapMarkerCell(tableView) cellConfiguration.configureGapMarker(cell, filling: syncIsFillingGap) + hideSeparator(for: cell) return cell } if recentlyBlockedSitePostObjectIDs.contains(post.objectID) { let cell = tableConfiguration.blockedSiteCell(tableView) - cellConfiguration.configureBlockedCell(cell, - withContent: content, - atIndexPath: indexPath) + cellConfiguration.configureBlockedCell(cell, withContent: content, atIndexPath: indexPath) + hideSeparator(for: cell) return cell } if post.isCross() { let cell = tableConfiguration.crossPostCell(tableView) - cellConfiguration.configureCrossPostCell(cell, - withContent: content, - atIndexPath: indexPath) + cellConfiguration.configureCrossPostCell(cell, withContent: content, atIndexPath: indexPath) + hideSeparator(for: cell) return cell } - let cell = tableConfiguration.postCardCell(tableView) - if isSidebarModeEnabled { - cell.enableSidebarMode() + guard FeatureFlag.readerReset.enabled else { + let cell = tableConfiguration.postCardCell(tableView) + if isSidebarModeEnabled { + cell.enableSidebarMode() + } + + let viewModel = ReaderPostCardCellViewModel(contentProvider: post, + isLoggedIn: isLoggedIn, + showsSeparator: showsSeparator, + parentViewController: self) + cell.configure(with: viewModel) + return cell } - let viewModel = ReaderPostCardCellViewModel(contentProvider: post, - isLoggedIn: isLoggedIn, - showsSeparator: showsSeparator, - parentViewController: self) - cell.configure(with: viewModel) + let cell = tableConfiguration.postCell(in: tableView, for: indexPath) + let viewModel = ReaderPostCellViewModel(post: post, topic: readerTopic) + viewModel.viewController = self + cell.configure(with: viewModel, isCompact: traitCollection.horizontalSizeClass == .compact, isSeparatorHidden: !showsSeparator) return cell } + func hideSeparator(for cell: UITableViewCell) { + guard FeatureFlag.readerReset.enabled else { return } + cell.separatorInset = UIEdgeInsets(.leading, 9999) + } + func cell(for tag: ReaderTagTopic) -> UITableViewCell { let cell = tableConfiguration.tagCell(tableView) diff --git a/WordPress/Classes/ViewRelated/Reader/ReaderTableConfiguration.swift b/WordPress/Classes/ViewRelated/Reader/ReaderTableConfiguration.swift index 0dd50d3a05ae..2061147b29bc 100644 --- a/WordPress/Classes/ViewRelated/Reader/ReaderTableConfiguration.swift +++ b/WordPress/Classes/ViewRelated/Reader/ReaderTableConfiguration.swift @@ -1,6 +1,7 @@ /// Registration and dequeuing of cells for table views in Reader final class ReaderTableConfiguration { private let footerViewNibName = "PostListFooterView" + private let readerPostCellReuseIdentifier = "ReaderPostCellReuseIdentifier" private let readerCardCellReuseIdentifier = "ReaderCardCellReuseIdentifier" private let readerBlockedCellNibName = "ReaderBlockedSiteCell" private let readerBlockedCellReuseIdentifier = "ReaderBlockedCellReuseIdentifier" @@ -21,6 +22,8 @@ final class ReaderTableConfiguration { setUpGapMarkerCell(tableView) setUpCrossPostCell(tableView) setUpTagCell(tableView) + + tableView.register(ReaderPostCell.self, forCellReuseIdentifier: readerPostCellReuseIdentifier) } private func setupAccessibility(_ tableView: UITableView) { @@ -28,7 +31,9 @@ final class ReaderTableConfiguration { } private func setUpSeparator(_ tableView: UITableView) { - tableView.separatorStyle = .none + if !FeatureFlag.readerReset.enabled { + tableView.separatorStyle = .none + } } private func setUpCardCell(_ tableView: UITableView) { @@ -75,6 +80,10 @@ final class ReaderTableConfiguration { return tableView.dequeueReusableCell(withIdentifier: readerCardCellReuseIdentifier) as! ReaderPostCardCell } + func postCell(in tableView: UITableView, for indexPath: IndexPath) -> ReaderPostCell { + tableView.dequeueReusableCell(withIdentifier: readerPostCellReuseIdentifier, for: indexPath) as! ReaderPostCell + } + func gapMarkerCell(_ tableView: UITableView) -> ReaderGapMarkerCell { return tableView.dequeueReusableCell(withIdentifier: readerGapMarkerCellReuseIdentifier) as! ReaderGapMarkerCell } diff --git a/WordPress/Classes/ViewRelated/Reader/Subscriptions/ReaderBlockingHelper.swift b/WordPress/Classes/ViewRelated/Reader/Subscriptions/ReaderBlockingHelper.swift new file mode 100644 index 000000000000..d764bc25abb4 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Reader/Subscriptions/ReaderBlockingHelper.swift @@ -0,0 +1,57 @@ +import Foundation + +struct ReaderBlockingHelper { + func blockSite(forPost post: ReaderPost, context: NSManagedObjectContext = ContextManager.shared.mainContext) { + postSiteBlockingWillBeginNotification(post) + + ReaderBlockSiteAction(asBlocked: true).execute(with: post, context: context, completion: { + ReaderHelpers.dispatchSiteBlockedMessage(post: post, success: true) + postSiteBlockingDidFinish(post) + }, failure: { error in + ReaderHelpers.dispatchSiteBlockedMessage(post: post, success: false) + postSiteBlockingDidFail(post, error: error) + }) + } + + func blockUser(forPost post: ReaderPost, context: NSManagedObjectContext = ContextManager.shared.mainContext) { + postUserBlockingWillBeginNotification(post) + ReaderBlockUserAction(context: context).execute(with: post, blocked: true) { result in + switch result { + case .success: + ReaderHelpers.dispatchUserBlockedMessage(post: post, success: true) + case .failure: + ReaderHelpers.dispatchUserBlockedMessage(post: post, success: false) + } + postUserBlockingDidFinishNotification(post, result: result) + } + } + + // MARK: Helpers + + private func postSiteBlockingWillBeginNotification(_ post: ReaderPost) { + NotificationCenter.default.post(name: .ReaderSiteBlockingWillBegin, object: nil, userInfo: [ReaderNotificationKeys.post: post]) + } + + /// Notify Reader Cards Stream so the post card is updated. + private func postSiteBlockingDidFinish(_ post: ReaderPost) { + NotificationCenter.default.post(name: .ReaderSiteBlocked, object: nil, userInfo: [ReaderNotificationKeys.post: post]) + } + + private func postSiteBlockingDidFail(_ post: ReaderPost, error: Error?) { + var userInfo: [String: Any] = [ReaderNotificationKeys.post: post] + if let error { + userInfo[ReaderNotificationKeys.error] = error + } + NotificationCenter.default.post(name: .ReaderSiteBlockingFailed, object: nil, userInfo: userInfo) + } + + private func postUserBlockingWillBeginNotification(_ post: ReaderPost) { + NotificationCenter.default.post(name: .ReaderUserBlockingWillBegin, object: nil, userInfo: [ReaderNotificationKeys.post: post]) + } + + private func postUserBlockingDidFinishNotification(_ post: ReaderPost, result: Result) { + let center = NotificationCenter.default + let userInfo: [String: Any] = [ReaderNotificationKeys.post: post, ReaderNotificationKeys.result: result] + center.post(name: .ReaderUserBlockingDidEnd, object: nil, userInfo: userInfo) + } +} diff --git a/WordPress/Classes/ViewRelated/Reader/Subscriptions/ReaderSubscriptionCell.swift b/WordPress/Classes/ViewRelated/Reader/Subscriptions/ReaderSubscriptionCell.swift index 00ee111333e0..72dd72ed96d3 100644 --- a/WordPress/Classes/ViewRelated/Reader/Subscriptions/ReaderSubscriptionCell.swift +++ b/WordPress/Classes/ViewRelated/Reader/Subscriptions/ReaderSubscriptionCell.swift @@ -75,7 +75,7 @@ struct ReaderSubscriptionCell: View { private var settings: some View { if horizontalSizeClass == .compact { ReaderSubscriptionNotificationSettingsView(siteID: site.siteID.intValue, isCompact: true) - .presentationDetents([.medium, .large]) + .presentationDetents([.medium, .large]) .edgesIgnoringSafeArea(.all) } else { ReaderSubscriptionNotificationSettingsView(siteID: site.siteID.intValue) @@ -110,6 +110,6 @@ private enum Strings { } private static func kFormatted(_ count: Int) -> String { - count >= 1000 ? String(format: "%.0fK", Double(count) / 1000) : String(count) + count.formatted(.number.notation(.compactName)) } } diff --git a/WordPress/Classes/ViewRelated/Reader/Subscriptions/ReaderSubscriptionHelper.swift b/WordPress/Classes/ViewRelated/Reader/Subscriptions/ReaderSubscriptionHelper.swift index e0c2095389f7..beda85902578 100644 --- a/WordPress/Classes/ViewRelated/Reader/Subscriptions/ReaderSubscriptionHelper.swift +++ b/WordPress/Classes/ViewRelated/Reader/Subscriptions/ReaderSubscriptionHelper.swift @@ -3,6 +3,23 @@ import SwiftUI struct ReaderSubscriptionHelper { let contextManager: CoreDataStackSwift = ContextManager.shared + // MARK: Subscribe + + func toggleSiteSubscription(forPost post: ReaderPost) { + let siteURL = post.blogURL.flatMap(URL.init) + ReaderFollowAction().execute(with: post, context: ContextManager.shared.mainContext, completion: { isFollowing in + UINotificationFeedbackGenerator().notificationOccurred(.success) + if isFollowing, let siteURL { + postSiteFollowedNotification(siteURL: siteURL) + } + ReaderHelpers.dispatchToggleFollowSiteMessage(post: post, follow: isFollowing, success: true) + }, failure: { _, _ in + UINotificationFeedbackGenerator().notificationOccurred(.error) + }) + } + + // MARK: Subscribe (RSS) + @MainActor func followSite(withURL siteURL: String) async throws { guard let url = makeURL(fromUserInput: siteURL) else { @@ -37,6 +54,8 @@ struct ReaderSubscriptionHelper { }) } + // MARK: Unsubscribe + @MainActor func unfollow(_ site: ReaderSiteTopic) { NotificationCenter.default.post(name: .ReaderTopicUnfollowed, object: nil, userInfo: [ReaderNotificationKeys.topic: site]) diff --git a/WordPress/Classes/ViewRelated/Reader/Subscriptions/ReaderSubscriptionNotificationSettingsView.swift b/WordPress/Classes/ViewRelated/Reader/Subscriptions/ReaderSubscriptionNotificationSettingsView.swift index ac88d38157de..9242977fe0e1 100644 --- a/WordPress/Classes/ViewRelated/Reader/Subscriptions/ReaderSubscriptionNotificationSettingsView.swift +++ b/WordPress/Classes/ViewRelated/Reader/Subscriptions/ReaderSubscriptionNotificationSettingsView.swift @@ -28,3 +28,27 @@ struct ReaderSubscriptionNotificationSettingsView: UIViewControllerRepresentable isCompact ? nil : CGSize(width: 320, height: 434) } } + +extension NotificationSiteSubscriptionViewController { + static func show( + forSiteID siteID: Int, + sourceItem: UIPopoverPresentationControllerSourceItem, + from presentingViewController: UIViewController + ) { + let isCompact = presentingViewController.traitCollection.horizontalSizeClass == .compact + let settingsVC = NotificationSiteSubscriptionViewController(siteId: siteID) + if isCompact { + settingsVC.navigationItem.rightBarButtonItem = UIBarButtonItem(title: SharedStrings.Button.done, primaryAction: .init { [weak presentingViewController] _ in + presentingViewController?.dismiss(animated: true) + }) + let navigationVC = UINavigationController(rootViewController: settingsVC) + navigationVC.sheetPresentationController?.detents = [.medium(), .large()] + presentingViewController.present(navigationVC, animated: true) + } else { + settingsVC.preferredContentSize = CGSize(width: 320, height: 434) + settingsVC.modalPresentationStyle = .popover + settingsVC.popoverPresentationController?.sourceItem = sourceItem + presentingViewController.present(settingsVC, animated: true) + } + } +} diff --git a/WordPress/WordPress.xcodeproj/project.pbxproj b/WordPress/WordPress.xcodeproj/project.pbxproj index dd6616dbeea4..c73973d87c53 100644 --- a/WordPress/WordPress.xcodeproj/project.pbxproj +++ b/WordPress/WordPress.xcodeproj/project.pbxproj @@ -545,6 +545,10 @@ 0C7E09252A4286F40052324C /* PostMetaButton+Swift.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C7E09232A4286F40052324C /* PostMetaButton+Swift.swift */; }; 0C8078AB2A4E01A5002ABF29 /* PagingFooterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C8078AA2A4E01A5002ABF29 /* PagingFooterView.swift */; }; 0C8078AC2A4E01A5002ABF29 /* PagingFooterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C8078AA2A4E01A5002ABF29 /* PagingFooterView.swift */; }; + 0C878AD02CBDACF7006CF997 /* ReaderPostMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C878ACF2CBDACF7006CF997 /* ReaderPostMenu.swift */; }; + 0C878AD12CBDACF7006CF997 /* ReaderPostMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C878ACF2CBDACF7006CF997 /* ReaderPostMenu.swift */; }; + 0C878AD32CBEE0EB006CF997 /* ReaderBlockingHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C878AD22CBEE0EB006CF997 /* ReaderBlockingHelper.swift */; }; + 0C878AD42CBEE0EB006CF997 /* ReaderBlockingHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C878AD22CBEE0EB006CF997 /* ReaderBlockingHelper.swift */; }; 0C896DDE2A3A762200D7D4E7 /* SettingsPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C896DDD2A3A762200D7D4E7 /* SettingsPicker.swift */; }; 0C896DE02A3A763400D7D4E7 /* SettingsCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C896DDF2A3A763400D7D4E7 /* SettingsCell.swift */; }; 0C896DE22A3A767200D7D4E7 /* SiteVisibility+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C896DE12A3A767200D7D4E7 /* SiteVisibility+Extensions.swift */; }; @@ -564,6 +568,10 @@ 0C8FC9AC2A8C57930059DCE4 /* test-webp.webp in Resources */ = {isa = PBXBuildFile; fileRef = 0C8FC9AB2A8C57930059DCE4 /* test-webp.webp */; }; 0C9434D62B687E38006AA6BC /* SiteMonitoringEntryDetailsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C9434D52B687E38006AA6BC /* SiteMonitoringEntryDetailsView.swift */; }; 0C9434D72B687E38006AA6BC /* SiteMonitoringEntryDetailsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C9434D52B687E38006AA6BC /* SiteMonitoringEntryDetailsView.swift */; }; + 0C9A78FF2CB971E30092D80E /* ReaderPostCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C9A78FE2CB971E30092D80E /* ReaderPostCell.swift */; }; + 0C9A79002CB971E30092D80E /* ReaderPostCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C9A78FE2CB971E30092D80E /* ReaderPostCell.swift */; }; + 0C9A79022CB9724A0092D80E /* ReaderPostCellViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C9A79012CB9724A0092D80E /* ReaderPostCellViewModel.swift */; }; + 0C9A79032CB9724A0092D80E /* ReaderPostCellViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C9A79012CB9724A0092D80E /* ReaderPostCellViewModel.swift */; }; 0C9CD79D2B9A6ABF0045BE03 /* PostRepositorySaveTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C9CD79C2B9A6ABF0045BE03 /* PostRepositorySaveTests.swift */; }; 0C9CD7A02B9A6FDC0045BE03 /* remote-post.json in Resources */ = {isa = PBXBuildFile; fileRef = 0C9CD79F2B9A6FDC0045BE03 /* remote-post.json */; }; 0CA10F6D2ADAE86D00CE75AC /* PostSearchSuggestionsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA10F6C2ADAE86D00CE75AC /* PostSearchSuggestionsService.swift */; }; @@ -694,6 +702,8 @@ 0CED200D2B68425A00E6DD52 /* WebKitView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CED200B2B68425A00E6DD52 /* WebKitView.swift */; }; 0CED95602A460F4B0020F420 /* DebugFeatureFlagsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CED955F2A460F4B0020F420 /* DebugFeatureFlagsView.swift */; }; 0CED95612A460F4B0020F420 /* DebugFeatureFlagsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CED955F2A460F4B0020F420 /* DebugFeatureFlagsView.swift */; }; + 0CEDBCFB2CB9A4BD00080019 /* FaviconService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CEDBCFA2CB9A4BD00080019 /* FaviconService.swift */; }; + 0CEDBCFC2CB9A4BD00080019 /* FaviconService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CEDBCFA2CB9A4BD00080019 /* FaviconService.swift */; }; 0CF0C4232AE98C13006FFAB4 /* AbstractPostHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CF0C4222AE98C13006FFAB4 /* AbstractPostHelper.swift */; }; 0CF0C4242AE98C13006FFAB4 /* AbstractPostHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CF0C4222AE98C13006FFAB4 /* AbstractPostHelper.swift */; }; 0CF22F5C2BC960430005B070 /* PrepublishingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CF22F5B2BC960430005B070 /* PrepublishingViewController.swift */; }; @@ -6487,6 +6497,8 @@ 0C7E09222A4286AA0052324C /* PostMetaButton.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = PostMetaButton.h; sourceTree = ""; }; 0C7E09232A4286F40052324C /* PostMetaButton+Swift.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PostMetaButton+Swift.swift"; sourceTree = ""; }; 0C8078AA2A4E01A5002ABF29 /* PagingFooterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PagingFooterView.swift; sourceTree = ""; }; + 0C878ACF2CBDACF7006CF997 /* ReaderPostMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderPostMenu.swift; sourceTree = ""; }; + 0C878AD22CBEE0EB006CF997 /* ReaderBlockingHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderBlockingHelper.swift; sourceTree = ""; }; 0C896DDD2A3A762200D7D4E7 /* SettingsPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsPicker.swift; sourceTree = ""; }; 0C896DDF2A3A763400D7D4E7 /* SettingsCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsCell.swift; sourceTree = ""; }; 0C896DE12A3A767200D7D4E7 /* SiteVisibility+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SiteVisibility+Extensions.swift"; sourceTree = ""; }; @@ -6498,6 +6510,8 @@ 0C8FC9A92A8C57000059DCE4 /* ItemProviderMediaExporterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemProviderMediaExporterTests.swift; sourceTree = ""; }; 0C8FC9AB2A8C57930059DCE4 /* test-webp.webp */ = {isa = PBXFileReference; lastKnownFileType = file; path = "test-webp.webp"; sourceTree = ""; }; 0C9434D52B687E38006AA6BC /* SiteMonitoringEntryDetailsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SiteMonitoringEntryDetailsView.swift; sourceTree = ""; }; + 0C9A78FE2CB971E30092D80E /* ReaderPostCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderPostCell.swift; sourceTree = ""; }; + 0C9A79012CB9724A0092D80E /* ReaderPostCellViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderPostCellViewModel.swift; sourceTree = ""; }; 0C9CD79C2B9A6ABF0045BE03 /* PostRepositorySaveTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostRepositorySaveTests.swift; sourceTree = ""; }; 0C9CD79F2B9A6FDC0045BE03 /* remote-post.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "remote-post.json"; sourceTree = ""; }; 0CA10F6C2ADAE86D00CE75AC /* PostSearchSuggestionsService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostSearchSuggestionsService.swift; sourceTree = ""; }; @@ -6567,6 +6581,7 @@ 0CED20082B68365200E6DD52 /* SiteMetricsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SiteMetricsView.swift; sourceTree = ""; }; 0CED200B2B68425A00E6DD52 /* WebKitView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebKitView.swift; sourceTree = ""; }; 0CED955F2A460F4B0020F420 /* DebugFeatureFlagsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebugFeatureFlagsView.swift; sourceTree = ""; }; + 0CEDBCFA2CB9A4BD00080019 /* FaviconService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FaviconService.swift; sourceTree = ""; }; 0CF0C4222AE98C13006FFAB4 /* AbstractPostHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AbstractPostHelper.swift; sourceTree = ""; }; 0CF22F5B2BC960430005B070 /* PrepublishingViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrepublishingViewController.swift; sourceTree = ""; }; 0CF22F5E2BC9605D0005B070 /* PrepublishingViewController+JetpackSocial.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PrepublishingViewController+JetpackSocial.swift"; sourceTree = ""; }; @@ -10629,6 +10644,7 @@ 0C1DB5FE2B095DA50028F200 /* ImageView.swift */, 0CBF66D72C949231005F1EDC /* ImageViewController.swift */, B5EB19EB20C6DACC008372B9 /* ImageDownloader.swift */, + 0CEDBCFA2CB9A4BD00080019 /* FaviconService.swift */, FADC40AD2A8D2E8D00C19997 /* ImageDownloader+Gravatar.swift */, 0C1DB6072B0A419B0028F200 /* ImageDecoder.swift */, 0CBF66DA2C949C1A005F1EDC /* UIImageView+ImageDownloader.swift */, @@ -10833,6 +10849,7 @@ 0CDB1BAF2C8B6BA700C83860 /* ReaderSubscriptionAddButton.swift */, 0CDB1BB52C8BC30900C83860 /* ReaderSubscriptionNotificationSettingsView.swift */, 0CDB1BB22C8B974300C83860 /* ReaderSubscriptionHelper.swift */, + 0C878AD22CBEE0EB006CF997 /* ReaderBlockingHelper.swift */, ); path = Subscriptions; sourceTree = ""; @@ -13388,6 +13405,8 @@ E6A3384F1BB0A70F00371587 /* ReaderGapMarkerCell.swift */, E6A3384D1BB0A50900371587 /* ReaderGapMarkerCell.xib */, 8386C6A22AC4E3C700568183 /* ReaderPostCardCell.swift */, + 0C9A78FE2CB971E30092D80E /* ReaderPostCell.swift */, + 0C9A79012CB9724A0092D80E /* ReaderPostCellViewModel.swift */, 83204EDB2ACE098B000C3229 /* ReaderPostCardCellViewModel.swift */, 3234BB322530EA980068DA40 /* ReaderRecommendedSiteCardCell.swift */, 3234BB332530EA980068DA40 /* ReaderRecommendedSiteCardCell.xib */, @@ -17127,6 +17146,7 @@ 981D464725B0D4E7000AA65C /* ReaderSeenAction.swift */, D8212CB620AA7703008E8AE8 /* ReaderShareAction.swift */, D8212CC020AA7C58008E8AE8 /* ReaderShowMenuAction.swift */, + 0C878ACF2CBDACF7006CF997 /* ReaderPostMenu.swift */, E6D6A12F2683ABE6004C24A7 /* ReaderSubscribeCommentsAction.swift */, D8212CB420AA68D5008E8AE8 /* ReaderSubscribingNotificationAction.swift */, D8212CBC20AA7A7A008E8AE8 /* ReaderVisitSiteAction.swift */, @@ -21597,6 +21617,7 @@ 40E728851FF3D9070010E7C9 /* PluginDetailViewHeaderCell.swift in Sources */, 46183D1F251BD6A0004F9AFD /* PageTemplateCategory+CoreDataClass.swift in Sources */, 9A2D0B25225CB980009E585F /* JetpackInstallStore.swift in Sources */, + 0C878AD02CBDACF7006CF997 /* ReaderPostMenu.swift in Sources */, 738B9A5421B85CF20005062B /* WizardNavigation.swift in Sources */, F1D690171F82914200200E30 /* BuildConfiguration.swift in Sources */, E1BEEC651C4E3978000B4FA0 /* PaddedLabel.swift in Sources */, @@ -21767,6 +21788,7 @@ 2F605FAA25145F7200F99544 /* WPCategoryTree.swift in Sources */, 5D62BAD718AA88210044E5F7 /* PageSettingsViewController.m in Sources */, 46241C3C2540D483002B8A12 /* SiteDesignContentCollectionViewController.swift in Sources */, + 0C9A79002CB971E30092D80E /* ReaderPostCell.swift in Sources */, 17D2FDC21C6A468A00944265 /* PlanComparisonViewController.swift in Sources */, 1715179220F4B2EB002C4A38 /* Routes+Stats.swift in Sources */, 74729CAA2057100200D1394D /* SearchIdentifierGenerator.swift in Sources */, @@ -22662,10 +22684,12 @@ 9F74696B209EFD0C0074D52B /* CheckmarkTableViewCell.swift in Sources */, F52CACCA244FA7AA00661380 /* ReaderManageScenePresenter.swift in Sources */, 0840513E2A4DDE3400A596E6 /* CompliancePopoverCoordinator.swift in Sources */, + 0C878AD42CBEE0EB006CF997 /* ReaderBlockingHelper.swift in Sources */, FF5371631FDFF64F00619A3F /* MediaService.swift in Sources */, 40ADB15520686870009A9161 /* PluginStore+Persistence.swift in Sources */, FE4DC5A3293A75FC008F322F /* MigrationDeepLinkRouter.swift in Sources */, D83CA3A720842CD90060E310 /* ResultsPage.swift in Sources */, + 0C9A79032CB9724A0092D80E /* ReaderPostCellViewModel.swift in Sources */, 7E929CD12110D4F200BCAD88 /* FormattableRangesFactory.swift in Sources */, 9815D0B326B49A0600DF7226 /* Comment+CoreDataProperties.swift in Sources */, 08D978551CD2AF7D0054F19A /* Menu+ViewDesign.m in Sources */, @@ -22905,6 +22929,7 @@ B5176CC11CDCE1B90083CF2D /* ManagedPerson.swift in Sources */, FA98B61C29A3DB840071AAE8 /* BlazeHelper.swift in Sources */, B54E1DF11A0A7BAA00807537 /* ReplyTextView.swift in Sources */, + 0CEDBCFB2CB9A4BD00080019 /* FaviconService.swift in Sources */, 931215EE267F6799008C3B69 /* ReferrerDetailsCell.swift in Sources */, 08216FCA1CDBF96000304BA7 /* MenuItemEditingFooterView.m in Sources */, E6D3E8491BEBD871002692E8 /* ReaderCrossPostCell.swift in Sources */, @@ -24993,6 +25018,7 @@ FEF9A74C2BA1CF370014045E /* ReaderTopicChangeObserver.swift in Sources */, FA4B203029A619130089FE68 /* BlazeFlowCoordinator.swift in Sources */, C7AFF875283C0ADC000E01DF /* UIApplication+Helpers.swift in Sources */, + 0CEDBCFC2CB9A4BD00080019 /* FaviconService.swift in Sources */, FABB22A52602FC2C00C8785C /* SignupEpilogueViewController.swift in Sources */, FAB985C22697550C00B172A3 /* NoResultsViewController+StatsModule.swift in Sources */, FA87A22F2BF798E40062154A /* Version.swift in Sources */, @@ -25141,6 +25167,7 @@ FABB23062602FC2C00C8785C /* Coordinate.m in Sources */, F1D8C6EC26BABE3E002E3323 /* WeeklyRoundupDebugScreen.swift in Sources */, 0CED20052B681EA400E6DD52 /* FilterCompactBar.swift in Sources */, + 0C878AD32CBEE0EB006CF997 /* ReaderBlockingHelper.swift in Sources */, 0C896DE52A3A7C1F00D7D4E7 /* SiteVisibility+Extensions.swift in Sources */, FABB23072602FC2C00C8785C /* SiteCreationRotatingMessageView.swift in Sources */, FABB23082602FC2C00C8785C /* SignupUsernameViewController.swift in Sources */, @@ -25560,6 +25587,7 @@ 0CE7833E2B08F3C300B114EB /* ExternalMediaPickerViewController.swift in Sources */, FABB24302602FC2C00C8785C /* ReaderReblogPresenter.swift in Sources */, 0CE538D12B0E317000834BA2 /* StockPhotosWelcomeView.swift in Sources */, + 0C878AD12CBDACF7006CF997 /* ReaderPostMenu.swift in Sources */, FABB24312602FC2C00C8785C /* BodyContentGroup.swift in Sources */, FABB24322602FC2C00C8785C /* WPStyleGuide+Search.swift in Sources */, FABB24332602FC2C00C8785C /* WindowManager.swift in Sources */, @@ -25656,6 +25684,7 @@ FABB24702602FC2C00C8785C /* String+Extensions.swift in Sources */, FABB24712602FC2C00C8785C /* CollapsableHeaderFilterBar.swift in Sources */, FABB24722602FC2C00C8785C /* AutomatedTransferHelper.swift in Sources */, + 0C9A79022CB9724A0092D80E /* ReaderPostCellViewModel.swift in Sources */, FABB24732602FC2C00C8785C /* PostTagService.m in Sources */, DC772AF4282009BA00664C02 /* StatsLineChartConfiguration.swift in Sources */, FABB24742602FC2C00C8785C /* ReaderTabViewModel.swift in Sources */, @@ -25971,6 +26000,7 @@ FABB25552602FC2C00C8785C /* Charts+AxisFormatters.swift in Sources */, 80D9CFFE29E711E200FE3400 /* DashboardPageCell.swift in Sources */, FABB25572602FC2C00C8785C /* EpilogueSectionHeaderFooter.swift in Sources */, + 0C9A78FF2CB971E30092D80E /* ReaderPostCell.swift in Sources */, 016231512B3B3CAD0010E377 /* PrimaryDomainView.swift in Sources */, FABB25582602FC2C00C8785C /* PostCategoriesViewController.swift in Sources */, FABB25592602FC2C00C8785C /* NSManagedObject+Lookup.swift in Sources */,