diff --git a/WordPress/Classes/Utility/CollectionView/AdaptiveCollectionViewFlowLayout.swift b/WordPress/Classes/Utility/CollectionView/AdaptiveCollectionViewFlowLayout.swift new file mode 100644 index 000000000000..58941b0345f9 --- /dev/null +++ b/WordPress/Classes/Utility/CollectionView/AdaptiveCollectionViewFlowLayout.swift @@ -0,0 +1,18 @@ +/// A flow layout that properly invalidates the layout when the collection view's bounds changed, +/// (e.g., orientation changes). +/// +/// This method ensures that we work with the latest/correct bounds after the size change, and potentially +/// avoids race conditions where we might get incorrect bounds while the view is still in transition. +/// +/// See: https://developer.apple.com/documentation/uikit/uicollectionviewlayout/1617781-shouldinvalidatelayout +class AdaptiveCollectionViewFlowLayout: UICollectionViewFlowLayout { + + override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool { + // NOTE: Apparently we need to *manually* invalidate the layout because `invalidateLayout()` + // is NOT called after this method returns true. + if let collectionView, collectionView.bounds.size != newBounds.size { + invalidateLayout() + } + return super.shouldInvalidateLayout(forBoundsChange: newBounds) + } +} diff --git a/WordPress/Classes/ViewRelated/Reader/ReaderTagCardCell.swift b/WordPress/Classes/ViewRelated/Reader/ReaderTagCardCell.swift index de7884ac049c..24d6f7639dac 100644 --- a/WordPress/Classes/ViewRelated/Reader/ReaderTagCardCell.swift +++ b/WordPress/Classes/ViewRelated/Reader/ReaderTagCardCell.swift @@ -123,6 +123,7 @@ private extension ReaderTagCardCell { func registerCells() { let tagCell = UINib(nibName: ReaderTagCell.classNameWithoutNamespaces(), bundle: nil) let footerView = UINib(nibName: ReaderTagFooterView.classNameWithoutNamespaces(), bundle: nil) + collectionView.register(ReaderTagCardEmptyCell.self, forCellWithReuseIdentifier: ReaderTagCardEmptyCell.defaultReuseID) collectionView.register(tagCell, forCellWithReuseIdentifier: ReaderTagCell.classNameWithoutNamespaces()) collectionView.register(footerView, forSupplementaryViewOfKind: UICollectionView.elementKindSectionFooter, diff --git a/WordPress/Classes/ViewRelated/Reader/ReaderTagCardCell.xib b/WordPress/Classes/ViewRelated/Reader/ReaderTagCardCell.xib index ed4a207514d1..210c8fdec766 100644 --- a/WordPress/Classes/ViewRelated/Reader/ReaderTagCardCell.xib +++ b/WordPress/Classes/ViewRelated/Reader/ReaderTagCardCell.xib @@ -25,12 +25,12 @@ - + - + @@ -43,7 +43,7 @@ - + diff --git a/WordPress/Classes/ViewRelated/Reader/ReaderTagCardCellViewModel.swift b/WordPress/Classes/ViewRelated/Reader/ReaderTagCardCellViewModel.swift index 1c6db21d25ff..cf3b182844f3 100644 --- a/WordPress/Classes/ViewRelated/Reader/ReaderTagCardCellViewModel.swift +++ b/WordPress/Classes/ViewRelated/Reader/ReaderTagCardCellViewModel.swift @@ -6,8 +6,18 @@ protocol ReaderTagCardCellViewModelDelegate: NSObjectProtocol { class ReaderTagCardCellViewModel: NSObject { - private typealias DataSource = UICollectionViewDiffableDataSource - private typealias Snapshot = NSDiffableDataSourceSnapshot + enum Section: Int { + case emptyState = 101 + case posts + } + + enum CardCellItem: Hashable { + case empty + case post(id: NSManagedObjectID) + } + + private typealias DataSource = UICollectionViewDiffableDataSource + private typealias Snapshot = NSDiffableDataSourceSnapshot let slug: String weak var viewDelegate: ReaderTagCardCellViewModelDelegate? = nil @@ -22,29 +32,21 @@ class ReaderTagCardCellViewModel: NSObject { .init(coreDataStack: coreDataStack) }() - private lazy var dataSource: DataSource? = { - guard let collectionView else { + private lazy var dataSource: DataSource? = { [weak self] in + guard let self, + let collectionView else { return nil } - let dataSource = DataSource(collectionView: collectionView) { [weak self] collectionView, indexPath, objectID in - guard let post = try? ContextManager.shared.mainContext.existingObject(with: objectID) as? ReaderPost, - let cell = collectionView.dequeueReusableCell(withReuseIdentifier: ReaderTagCell.classNameWithoutNamespaces(), for: indexPath) as? ReaderTagCell else { - return UICollectionViewCell() - } - cell.configure(parent: self?.parentViewController, - post: post, - isLoggedIn: self?.isLoggedIn ?? AccountHelper.isLoggedIn) - return cell - } - dataSource.supplementaryViewProvider = { [weak self] collectionView, kind, indexPath in - guard let slug = self?.slug, - kind == UICollectionView.elementKindSectionFooter, + + let dataSource = DataSource(collectionView: collectionView, cellProvider: self.cardCellProvider) + dataSource.supplementaryViewProvider = { collectionView, kind, indexPath in + guard kind == UICollectionView.elementKindSectionFooter, let view = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: ReaderTagFooterView.classNameWithoutNamespaces(), for: indexPath) as? ReaderTagFooterView else { return nil } - view.configure(with: slug) { [weak self] in + view.configure(with: self.slug) { [weak self] in self?.onTagButtonTapped() } return view @@ -65,6 +67,8 @@ class ReaderTagCardCellViewModel: NSObject { return resultsController }() + // MARK: Methods + init(parent: UIViewController?, tag: ReaderTagTopic, collectionView: UICollectionView?, @@ -122,13 +126,65 @@ class ReaderTagCardCellViewModel: NSObject { } +// MARK: - Private Methods + +private extension ReaderTagCardCellViewModel { + /// Configures and returns a collection view cell according to the index path and `CardCellItem`. + /// This method that satisfies the `UICollectionViewDiffableDataSource.CellProvider` closure signature. + func cardCellProvider(_ collectionView: UICollectionView, + _ indexPath: IndexPath, + _ item: CardCellItem) -> UICollectionViewCell? { + switch item { + case .empty: + guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: ReaderTagCardEmptyCell.defaultReuseID, + for: indexPath) as? ReaderTagCardEmptyCell else { + return UICollectionViewCell() + } + + cell.configure(tagTitle: slug) { [weak self] in + self?.fetchTagPosts(syncRemotely: true) + } + + return cell + + case .post(let objectID): + guard let post = try? ContextManager.shared.mainContext.existingObject(with: objectID) as? ReaderPost, + let cell = collectionView.dequeueReusableCell(withReuseIdentifier: ReaderTagCell.classNameWithoutNamespaces(), + for: indexPath) as? ReaderTagCell else { + return UICollectionViewCell() + } + + cell.configure(parent: parentViewController, post: post, isLoggedIn: isLoggedIn) + return cell + } + } + + /// Translates a diffable snapshot from `NSFetchedResultsController` to a snapshot that fits the collection view. + /// + /// Snapshots returned from `NSFetchedResultsController` always have the type ``, so + /// it needs to be converted to match the correct type required by the collection view. + /// + /// - Parameter snapshotRef: The snapshot returned from the `NSFetchedResultsController` + /// - Returns: `Snapshot` + private func collectionViewSnapshot(from snapshotRef: NSDiffableDataSourceSnapshotReference) -> Snapshot { + let coreDataSnapshot = snapshotRef as NSDiffableDataSourceSnapshot + let isEmpty = coreDataSnapshot.numberOfItems == .zero + var snapshot = Snapshot() + + // there must be at least one section. + snapshot.appendSections([isEmpty ? .emptyState : .posts]) + snapshot.appendItems(isEmpty ? [.empty] : coreDataSnapshot.itemIdentifiers.map { .post(id: $0) }) + + return snapshot + } +} + // MARK: - NSFetchedResultsControllerDelegate extension ReaderTagCardCellViewModel: NSFetchedResultsControllerDelegate { - func controller(_ controller: NSFetchedResultsController, didChangeContentWith snapshot: NSDiffableDataSourceSnapshotReference) { - dataSource?.apply(snapshot as Snapshot, animatingDifferences: false) { [weak self] in + dataSource?.apply(collectionViewSnapshot(from: snapshot), animatingDifferences: false) { [weak self] in self?.viewDelegate?.hideLoading() } } @@ -140,7 +196,9 @@ extension ReaderTagCardCellViewModel: NSFetchedResultsControllerDelegate { extension ReaderTagCardCellViewModel: UICollectionViewDelegate { func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { - guard let sectionInfo = resultsController.sections?[safe: indexPath.section], + guard let section = dataSource?.sectionIdentifier(for: indexPath.section), + section == .posts, + let sectionInfo = resultsController.sections?[safe: indexPath.section], indexPath.row < sectionInfo.numberOfObjects else { return } @@ -156,10 +214,25 @@ extension ReaderTagCardCellViewModel: UICollectionViewDelegate { extension ReaderTagCardCellViewModel: UICollectionViewDelegateFlowLayout { func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize { - return cellSize() ?? .zero + guard let section = dataSource?.sectionIdentifier(for: indexPath.section), + let size = cellSize() else { + return .zero + } + + switch section { + case .emptyState: + return CGSize(width: collectionView.frame.width, height: size.height) + case .posts: + return size + } } func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, referenceSizeForFooterInSection section: Int) -> CGSize { + guard let sectionIdentifier = dataSource?.sectionIdentifier(for: section), + sectionIdentifier == .posts else { + return .zero + } + var viewSize = cellSize() ?? .zero viewSize.width = Constants.footerWidth return viewSize diff --git a/WordPress/Classes/ViewRelated/Reader/ReaderTagCardEmptyCell.swift b/WordPress/Classes/ViewRelated/Reader/ReaderTagCardEmptyCell.swift new file mode 100644 index 000000000000..3a9516e8f6c2 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Reader/ReaderTagCardEmptyCell.swift @@ -0,0 +1,120 @@ +import SwiftUI +import DesignSystem + +class ReaderTagCardEmptyCell: UICollectionViewCell, Reusable { + + var tagTitle: String { + get { + swiftUIView.tagTitle + } + set { + swiftUIView.tagTitle = newValue + } + } + + var retryHandler: (() -> Void)? = nil + + private lazy var swiftUIView: ReaderTagCardEmptyCellView = { + ReaderTagCardEmptyCellView(buttonTapped: { [weak self] in + self?.retryHandler?() + }) + }() + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override init(frame: CGRect) { + super.init(frame: .zero) + + let viewToEmbed = UIView.embedSwiftUIView(swiftUIView) + contentView.addSubview(viewToEmbed) + contentView.pinSubviewToAllEdges(viewToEmbed) + } + + override func prepareForReuse() { + tagTitle = String() + retryHandler = nil + super.prepareForReuse() + } + + func configure(tagTitle: String, retryHandler: (() -> Void)?) { + self.tagTitle = tagTitle + self.retryHandler = retryHandler + } +} + +// MARK: - SwiftUI + +private struct ReaderTagCardEmptyCellView: View { + + var tagTitle = String() + var buttonTapped: (() -> Void)? = nil + + @ScaledMetric(relativeTo: Font.TextStyle.callout) + private var iconLength = 32.0 + + var body: some View { + VStack(spacing: .DS.Padding.double) { + Image(systemName: "wifi.slash") + .resizable() + .frame(width: iconLength, height: iconLength) + .foregroundStyle(Color.DS.Foreground.secondary) + + // added to double the padding between the Image and the VStack. + Spacer().frame(height: .hairlineBorderWidth) + + VStack(spacing: .DS.Padding.single) { + Text(Strings.title) + .font(.callout) + .fontWeight(.semibold) + + Text(Strings.body) + .font(.callout) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + + Button { + buttonTapped?() + } label: { + Text(Strings.buttonTitle) + .font(.callout) + .padding(.vertical, .DS.Padding.half) + .padding(.horizontal, .DS.Padding.single) + } + } + .padding(.DS.Padding.single) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) + } + + private struct Strings { + static let title = NSLocalizedString( + "reader.tagStream.cards.emptyView.error.title", + value: "Posts failed to load", + comment: """ + The title of an empty state component for one of the tags in the tag stream. + This empty state component is displayed only when the app fails to load posts under this tag. + """ + ) + + static let body = NSLocalizedString( + "reader.tagStream.cards.emptyView.error.body", + value: "We couldn't load posts from this tag right now", + comment: """ + The body text of an empty state component for one of the tags in the tag stream. + This empty state component is displayed only when the app fails to load posts under this tag. + """ + ) + + static let buttonTitle = NSLocalizedString( + "reader.tagStream.cards.emptyView.button", + value: "Retry", + comment: """ + Verb. The button title of an empty state component for one of the tags in the tag stream. + This empty state component is displayed only when the app fails to load posts under this tag. + When tapped, the app will try to reload posts under this tag. + """ + ) + } +} diff --git a/WordPress/WordPress.xcodeproj/project.pbxproj b/WordPress/WordPress.xcodeproj/project.pbxproj index 586ae2a4ff51..971983059e46 100644 --- a/WordPress/WordPress.xcodeproj/project.pbxproj +++ b/WordPress/WordPress.xcodeproj/project.pbxproj @@ -5653,6 +5653,8 @@ FE23EB4A26E7C91F005A1698 /* richCommentTemplate.html in Resources */ = {isa = PBXBuildFile; fileRef = FE23EB4726E7C91F005A1698 /* richCommentTemplate.html */; }; FE23EB4B26E7C91F005A1698 /* richCommentStyle.css in Resources */ = {isa = PBXBuildFile; fileRef = FE23EB4826E7C91F005A1698 /* richCommentStyle.css */; }; FE23EB4C26E7C91F005A1698 /* richCommentStyle.css in Resources */ = {isa = PBXBuildFile; fileRef = FE23EB4826E7C91F005A1698 /* richCommentStyle.css */; }; + FE2590992BE3F4E2005690E9 /* ReaderTagCardEmptyCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE2590982BE3F4E2005690E9 /* ReaderTagCardEmptyCell.swift */; }; + FE25909A2BE3F4E2005690E9 /* ReaderTagCardEmptyCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE2590982BE3F4E2005690E9 /* ReaderTagCardEmptyCell.swift */; }; FE25C235271F23000084E1DB /* ReaderCommentsNotificationSheetViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE25C234271F23000084E1DB /* ReaderCommentsNotificationSheetViewController.swift */; }; FE25C236271F23000084E1DB /* ReaderCommentsNotificationSheetViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE25C234271F23000084E1DB /* ReaderCommentsNotificationSheetViewController.swift */; }; FE29EFCD29A91160007CE034 /* WPAdminConvertibleRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE29EFCC29A91160007CE034 /* WPAdminConvertibleRouter.swift */; }; @@ -5734,6 +5736,8 @@ FEAA6F79298CE4A600ADB44C /* PluginJetpackProxyServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEAA6F78298CE4A600ADB44C /* PluginJetpackProxyServiceTests.swift */; }; FEAC916E28001FC4005026E7 /* AvatarTrainView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEAC916D28001FC4005026E7 /* AvatarTrainView.swift */; }; FEAC916F28001FC4005026E7 /* AvatarTrainView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEAC916D28001FC4005026E7 /* AvatarTrainView.swift */; }; + FEC1B0CE2BE41A7400CB4A3D /* AdaptiveCollectionViewFlowLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEC1B0CD2BE41A7400CB4A3D /* AdaptiveCollectionViewFlowLayout.swift */; }; + FEC1B0CF2BE41E1C00CB4A3D /* AdaptiveCollectionViewFlowLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEC1B0CD2BE41A7400CB4A3D /* AdaptiveCollectionViewFlowLayout.swift */; }; FEC26030283FBA1A003D886A /* BloggingPromptCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEC2602F283FBA1A003D886A /* BloggingPromptCoordinator.swift */; }; FEC26031283FBA1A003D886A /* BloggingPromptCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEC2602F283FBA1A003D886A /* BloggingPromptCoordinator.swift */; }; FEC26033283FC902003D886A /* RootViewCoordinator+BloggingPrompt.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEC26032283FC902003D886A /* RootViewCoordinator+BloggingPrompt.swift */; }; @@ -9585,6 +9589,7 @@ FE1E201D2A49D59400CE7C90 /* JetpackSocialServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JetpackSocialServiceTests.swift; sourceTree = ""; }; FE23EB4726E7C91F005A1698 /* richCommentTemplate.html */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.html; name = richCommentTemplate.html; path = Resources/HTML/richCommentTemplate.html; sourceTree = ""; }; FE23EB4826E7C91F005A1698 /* richCommentStyle.css */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.css; name = richCommentStyle.css; path = Resources/HTML/richCommentStyle.css; sourceTree = ""; }; + FE2590982BE3F4E2005690E9 /* ReaderTagCardEmptyCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderTagCardEmptyCell.swift; sourceTree = ""; }; FE25C234271F23000084E1DB /* ReaderCommentsNotificationSheetViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderCommentsNotificationSheetViewController.swift; sourceTree = ""; }; FE29EFCC29A91160007CE034 /* WPAdminConvertibleRouter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WPAdminConvertibleRouter.swift; sourceTree = ""; }; FE2E3728281C839C00A1E82A /* BloggingPromptsServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BloggingPromptsServiceTests.swift; sourceTree = ""; }; @@ -9638,6 +9643,7 @@ FEAA6F78298CE4A600ADB44C /* PluginJetpackProxyServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PluginJetpackProxyServiceTests.swift; sourceTree = ""; }; FEAC916D28001FC4005026E7 /* AvatarTrainView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AvatarTrainView.swift; sourceTree = ""; }; FEB7A8922718852A00A8CF85 /* WordPress 134.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "WordPress 134.xcdatamodel"; sourceTree = ""; }; + FEC1B0CD2BE41A7400CB4A3D /* AdaptiveCollectionViewFlowLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdaptiveCollectionViewFlowLayout.swift; sourceTree = ""; }; FEC2602F283FBA1A003D886A /* BloggingPromptCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BloggingPromptCoordinator.swift; sourceTree = ""; }; FEC26032283FC902003D886A /* RootViewCoordinator+BloggingPrompt.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RootViewCoordinator+BloggingPrompt.swift"; sourceTree = ""; }; FECA442E28350B7800D01F15 /* PromptRemindersScheduler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PromptRemindersScheduler.swift; sourceTree = ""; }; @@ -12805,6 +12811,7 @@ 83BF48BD2BD6FA3000C0E1A1 /* ReaderTagCellViewModel.swift */, 83A8A2922BDC557E001F9133 /* ReaderTagFooterView.swift */, 83A8A2932BDC557E001F9133 /* ReaderTagFooterView.xib */, + FE2590982BE3F4E2005690E9 /* ReaderTagCardEmptyCell.swift */, ); name = Cards; sourceTree = ""; @@ -14037,6 +14044,7 @@ 8584FDB4192437160019C02E /* Utility */ = { isa = PBXGroup; children = ( + FEC1B0CC2BE41A5F00CB4A3D /* CollectionView */, F4FF50E42B4D7D590076DB0C /* In-App Feedback */, FE6BB14129322798001E5F7A /* Migration */, 8B85AED8259230C500ADBEC9 /* AB Testing */, @@ -18879,6 +18887,14 @@ name = Detail; sourceTree = ""; }; + FEC1B0CC2BE41A5F00CB4A3D /* CollectionView */ = { + isa = PBXGroup; + children = ( + FEC1B0CD2BE41A7400CB4A3D /* AdaptiveCollectionViewFlowLayout.swift */, + ); + path = CollectionView; + sourceTree = ""; + }; FEC2602D283FB9D4003D886A /* Blogging Prompts */ = { isa = PBXGroup; children = ( @@ -22594,6 +22610,7 @@ E62CE58E26B1D14200C9D147 /* AccountService+Cookies.swift in Sources */, 3F4A4C232AD3FA2E00DE5DF8 /* MySiteViewModel.swift in Sources */, FACF66D02ADD6CD8008C3E13 /* PostListItemViewModel.swift in Sources */, + FE2590992BE3F4E2005690E9 /* ReaderTagCardEmptyCell.swift in Sources */, B0F2EFBF259378E600C7EB6D /* SiteSuggestionService.swift in Sources */, 4388FF0020A4E19C00783948 /* NotificationsViewController+PushPrimer.swift in Sources */, 800035BD291DD0D7007D2D26 /* JetpackFullscreenOverlayGeneralViewModel+Analytics.swift in Sources */, @@ -22617,6 +22634,7 @@ E1BEEC631C4E35A8000B4FA0 /* Animator.swift in Sources */, 981C3494218388CA00FC2683 /* SiteStatsDashboardViewController.swift in Sources */, E1C5457E1C6B962D001CEB0E /* MediaSettings.swift in Sources */, + FEC1B0CE2BE41A7400CB4A3D /* AdaptiveCollectionViewFlowLayout.swift in Sources */, 02BF30532271D7F000616558 /* DomainCreditRedemptionSuccessViewController.swift in Sources */, 93C4864F181043D700A24725 /* ActivityLogDetailViewController.m in Sources */, B57B99DE19A2DBF200506504 /* NSObject+Helpers.m in Sources */, @@ -25166,6 +25184,7 @@ FABB22FB2602FC2C00C8785C /* ReaderFollowAction.swift in Sources */, FABB22FC2602FC2C00C8785C /* SheetActions.swift in Sources */, FABB22FD2602FC2C00C8785C /* ReaderTracker.swift in Sources */, + FEC1B0CF2BE41E1C00CB4A3D /* AdaptiveCollectionViewFlowLayout.swift in Sources */, FABB22FE2602FC2C00C8785C /* PlanFeature.swift in Sources */, 17C1D7DD26735631006C8970 /* EmojiRenderer.swift in Sources */, FABB22FF2602FC2C00C8785C /* Spotlightable.swift in Sources */, @@ -25721,6 +25740,7 @@ 0CED200D2B68425A00E6DD52 /* WebKitView.swift in Sources */, FABB247F2602FC2C00C8785C /* StockPhotosPageable.swift in Sources */, FABB24802602FC2C00C8785C /* JetpackRestoreStatusViewController.swift in Sources */, + FE25909A2BE3F4E2005690E9 /* ReaderTagCardEmptyCell.swift in Sources */, B038A81E2BD70FCA00763731 /* StatsSubscribersChartCell.swift in Sources */, FABB24812602FC2C00C8785C /* BindableTapGestureRecognizer.swift in Sources */, FABB24822602FC2C00C8785C /* ReaderSearchSuggestion.swift in Sources */,