From cfc63102d438ab0201a8fce999439efef0717cc1 Mon Sep 17 00:00:00 2001 From: Tony Li Date: Wed, 19 Aug 2026 18:28:41 +1200 Subject: [PATCH] Replace Stats and Reader likes screens with an adaptive SwiftUI grid The likers list (users who liked a post) rendered as a single full-width table everywhere, wasting space on iPad's regular width. Replace the Stats and Reader post-likes screens with one shared SwiftUI implementation that adapts to the horizontal size class: a single column on compact and an adaptive multi-column grid on regular. LikesListViewModel ports the post-likes fetch and pagination engine from LikesListController (cache-first first load, the +1s "before" cursor, and the excluded-IDs boundary logic), publishing the likes, total, loading, and error state. LikesListView renders both layouts from one LazyVGrid; LikeUserRowView is a visual port of LikeUserTableViewCell. Each row is a Button so VoiceOver and Switch Control get one labeled control per row, and the avatar scales with Dynamic Type. A single LikesListHostViewController wrapper replaces the two near-duplicate host controllers, owning the navigation title, per-feature analytics, and the user profile sheet. A small PostLikesServing protocol keeps the view model unit-testable. Notifications is out of scope and keeps the existing LikesListController path. --- .../Likes/LikesListViewModelTests.swift | 196 ++++++++++++++++ .../ViewRelated/Likes/LikeUserRowView.swift | 65 +++++ .../Likes/LikesListHostViewController.swift | 120 ++++++++++ .../ViewRelated/Likes/LikesListView.swift | 97 ++++++++ .../Likes/LikesListViewModel.swift | 222 ++++++++++++++++++ .../Detail/ReaderDetailCoordinator.swift | 4 +- .../ReaderDetailLikesListController.swift | 98 -------- .../Stats/StatsHostingViewController.swift | 2 +- .../Stats/StatsLikesListViewController.swift | 106 --------- 9 files changed, 704 insertions(+), 206 deletions(-) create mode 100644 Tests/KeystoneTests/Tests/ViewRelated/Likes/LikesListViewModelTests.swift create mode 100644 WordPress/Classes/ViewRelated/Likes/LikeUserRowView.swift create mode 100644 WordPress/Classes/ViewRelated/Likes/LikesListHostViewController.swift create mode 100644 WordPress/Classes/ViewRelated/Likes/LikesListView.swift create mode 100644 WordPress/Classes/ViewRelated/Likes/LikesListViewModel.swift delete mode 100644 WordPress/Classes/ViewRelated/Reader/Detail/Views/ReaderDetailLikesListController.swift delete mode 100644 WordPress/Classes/ViewRelated/Stats/StatsLikesListViewController.swift diff --git a/Tests/KeystoneTests/Tests/ViewRelated/Likes/LikesListViewModelTests.swift b/Tests/KeystoneTests/Tests/ViewRelated/Likes/LikesListViewModelTests.swift new file mode 100644 index 000000000000..412d5a62f698 --- /dev/null +++ b/Tests/KeystoneTests/Tests/ViewRelated/Likes/LikesListViewModelTests.swift @@ -0,0 +1,196 @@ +import Foundation +import Testing +import WordPressKit +@testable import WordPress +@testable import WordPressData + +@MainActor +@Suite("LikesListViewModel Tests") +struct LikesListViewModelTests { + private let contextManager = ContextManager.forTesting() + private let siteID = NSNumber(value: 1) + private let postID = NSNumber(value: 2) + + // MARK: - First load + + @Test("First load seeds the cached likes from Core Data") + func firstLoadSeedsCache() { + // Offline so the remote fetch is skipped and only the cache seed is observed. + makeNetworkUnavailable() + let cached = [makeUser(id: 10), makeUser(id: 11)] + let service = FakePostLikesService() + service.storedUsers = cached + + let viewModel = LikesListViewModel(siteID: siteID, postID: postID, totalLikes: 2, service: service) + viewModel.loadMore() + + #expect(viewModel.users.map { $0.userID } == [10, 11]) + #expect(viewModel.error == nil) + #expect(service.getLikesCallCount == 0) + } + + @Test("Offline with an empty cache surfaces the error state") + func offlineEmptyCacheShowsError() { + makeNetworkUnavailable() + let service = FakePostLikesService() + + let viewModel = LikesListViewModel(siteID: siteID, postID: postID, totalLikes: 0, service: service) + viewModel.loadMore() + + #expect(viewModel.users.isEmpty) + #expect(viewModel.error != nil) + #expect(viewModel.error?.subtitle == nil) + } + + // MARK: - Pagination cursor + + @Test("The second page sends the +1s before cursor and the excluded IDs") + func paginationCursor() { + makeNetworkAvailable() + + let boundaryDate = Date(timeIntervalSince1970: 1_000_000) + let page1 = [makeUser(id: 20), makeUser(id: 21, dateLiked: boundaryDate)] + let page2 = [makeUser(id: 20), makeUser(id: 21, dateLiked: boundaryDate), makeUser(id: 22)] + let excluded = [makeUser(id: 21, dateLiked: boundaryDate)] + + let service = FakePostLikesService() + service.totalLikes = 3 + service.pages = [page1, page2] + service.storedUsersAfter = excluded + + let viewModel = LikesListViewModel(siteID: siteID, postID: postID, totalLikes: 3, service: service) + + // First page: no cursor, no exclusions, purges existing. + viewModel.loadMore() + #expect(service.getLikesCallCount == 1) + #expect(service.lastBefore == nil) + #expect(service.lastExcludingIDs == nil) + #expect(service.lastPurgeExisting == true) + + // Second page: cursor is the boundary date + 1 second, plus the excluded IDs. + viewModel.loadMore() + #expect(service.getLikesCallCount == 2) + #expect(service.lastBefore == expectedBeforeString(from: boundaryDate)) + #expect(service.lastExcludingIDs == [NSNumber(value: 21)]) + #expect(service.lastPurgeExisting == false) + } + + @Test("No further page is fetched once every like has been loaded") + func hasMoreGuardStopsPaging() { + makeNetworkAvailable() + + let page = [makeUser(id: 30), makeUser(id: 31)] + let service = FakePostLikesService() + service.totalLikes = 2 + service.pages = [page] + + let viewModel = LikesListViewModel(siteID: siteID, postID: postID, totalLikes: 2, service: service) + viewModel.loadMore() + #expect(service.getLikesCallCount == 1) + #expect(viewModel.hasMoreLikes == false) + + // Displaying the last row must not trigger another fetch. + viewModel.loadMoreIfNeeded(displaying: page[1]) + #expect(service.getLikesCallCount == 1) + } + + // MARK: - Error mapping + + @Test("An authorization-required failure maps to the private-blog message") + func privateBlogErrorMapping() { + makeNetworkAvailable() + + let service = FakePostLikesService() + service.failureError = NSError( + domain: WordPressComRestApiEndpointError.errorDomain, + code: WordPressComRestApiErrorCode.authorizationRequired.rawValue + ) + + let viewModel = LikesListViewModel(siteID: siteID, postID: postID, totalLikes: 0, service: service) + viewModel.loadMore() + + #expect(viewModel.error != nil) + #expect(viewModel.error?.subtitle == "You don't have permission to view this private blog.") + } + + @Test("A generic failure has no subtitle") + func genericErrorMapping() { + makeNetworkAvailable() + + let service = FakePostLikesService() + service.failureError = NSError(domain: "test", code: 500) + + let viewModel = LikesListViewModel(siteID: siteID, postID: postID, totalLikes: 0, service: service) + viewModel.loadMore() + + #expect(viewModel.error != nil) + #expect(viewModel.error?.subtitle == nil) + } + + // MARK: - Helpers + + private func makeUser(id: Int64, dateLiked: Date = Date(timeIntervalSince1970: 0)) -> LikeUser { + let user = LikeUser(context: contextManager.mainContext) + user.userID = id + user.username = "user\(id)" + user.displayName = "User \(id)" + user.avatarUrl = "" + user.likedSiteID = siteID.int64Value + user.likedPostID = postID.int64Value + user.dateLiked = dateLiked + user.dateLikedString = "date-\(id)" + user.dateFetched = Date(timeIntervalSince1970: 0) + return user + } + + /// Replicates the view model's cursor formatting: the boundary date bumped by one + /// second, formatted "YYYY-MM-DD HH:MM:SS" (no T/Z). + private func expectedBeforeString(from date: Date) -> String { + let bumped = Calendar.current.date(byAdding: .second, value: 1, to: date)! + return ISO8601DateFormatter() + .string(from: bumped) + .replacingOccurrences(of: "T", with: " ") + .replacingOccurrences(of: "Z", with: "") + } +} + +/// A fake `PostLikesServing` that returns queued pages and records the pagination cursor. +/// Nonisolated to match the production `PostService`, which is not main-actor isolated. +private final class FakePostLikesService: PostLikesServing { + var storedUsers: [LikeUser] = [] + var storedUsersAfter: [LikeUser] = [] + var pages: [[LikeUser]] = [] + var totalLikes = 0 + var failureError: Error? + + private(set) var getLikesCallCount = 0 + private(set) var lastBefore: String? + private(set) var lastExcludingIDs: [NSNumber]? + private(set) var lastPurgeExisting: Bool? + + func likeUsersFor(postID: NSNumber, siteID: NSNumber, after: Date?) -> [LikeUser] { + return after == nil ? storedUsers : storedUsersAfter + } + + func getLikesFor(postID: NSNumber, + siteID: NSNumber, + count: Int, + before: String?, + excludingIDs: [NSNumber]?, + purgeExisting: Bool, + success: @escaping (([LikeUser], Int, Int) -> Void), + failure: @escaping ((Error?) -> Void)) { + getLikesCallCount += 1 + lastBefore = before + lastExcludingIDs = excludingIDs + lastPurgeExisting = purgeExisting + + if let failureError { + failure(failureError) + return + } + + let users = pages.isEmpty ? [] : pages.removeFirst() + success(users, totalLikes, count) + } +} diff --git a/WordPress/Classes/ViewRelated/Likes/LikeUserRowView.swift b/WordPress/Classes/ViewRelated/Likes/LikeUserRowView.swift new file mode 100644 index 000000000000..4075f7839c3b --- /dev/null +++ b/WordPress/Classes/ViewRelated/Likes/LikeUserRowView.swift @@ -0,0 +1,65 @@ +import SwiftUI +import WordPressData +import WordPressUI + +/// SwiftUI port of `LikeUserTableViewCell`: a 46pt circular avatar, the display name, +/// the `@username`, and a hairline bottom divider. Used in both the compact (single +/// column) and regular (multi-column) layouts of ``LikesListView``. +struct LikeUserRowView: View { + let user: LikeUser + + /// Whether to draw the hairline bottom divider. The multi-column grid layout hides it + /// so cells don't carry stray separators; the single-column layout keeps it to match + /// the table it replaces. + var showsDivider = true + + var body: some View { + VStack(spacing: 0) { + HStack(spacing: Metrics.avatarSpacing) { + // The avatar sizes itself (it scales with Dynamic Type via an internal + // @ScaledMetric), so it must not be pinned to a fixed frame here or it + // would overflow and overlap the text at large accessibility sizes. + AvatarView( + style: .single(URL(string: user.avatarUrl)), + diameter: Metrics.avatarDiameter, + placeholderImage: Image("gravatar").resizable() + ) + .accessibilityHidden(true) + + VStack(alignment: .leading, spacing: Metrics.labelSpacing) { + Text(user.displayName) + .font(.body) + .foregroundStyle(.primary) + Text(String(format: Strings.usernameFormat, user.username)) + .font(.subheadline) + .foregroundStyle(.secondary) + } + + Spacer(minLength: 0) + } + .padding(.horizontal, Metrics.horizontalPadding) + .padding(.vertical, Metrics.verticalPadding) + + if showsDivider { + Divider() + .padding(.leading, Metrics.horizontalPadding) + } + } + } + + private enum Metrics { + static let avatarDiameter: CGFloat = 46 + static let avatarSpacing: CGFloat = 12 + static let labelSpacing: CGFloat = 2 + static let horizontalPadding: CGFloat = 20 + static let verticalPadding: CGFloat = 12 + } + + private enum Strings { + static let usernameFormat = NSLocalizedString( + "@%1$@", + comment: + "Label displaying the user's username preceded by an '@' symbol. %1$@ is a placeholder for the username." + ) + } +} diff --git a/WordPress/Classes/ViewRelated/Likes/LikesListHostViewController.swift b/WordPress/Classes/ViewRelated/Likes/LikesListHostViewController.swift new file mode 100644 index 000000000000..3ba1baa8f650 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Likes/LikesListHostViewController.swift @@ -0,0 +1,120 @@ +import Combine +import SwiftUI +import UIKit +import WordPressData +import WordPressShared + +/// Hosts the shared SwiftUI ``LikesListView`` for the Stats and Reader post-likes screens, +/// replacing the near-duplicate `StatsLikesListViewController` and +/// `ReaderDetailLikesListController`. It owns the navigation title, forwards the +/// per-feature analytics, and presents the user profile sheet on row taps. +final class LikesListHostViewController: UIHostingController { + + /// Per-feature analytics identifiers, preserving the events emitted by the previous hosts. + struct Configuration { + let likeListOpenedSource: String + let userProfileSheetShownSource: String + let blogUrlPreviewedSource: String + + static let stats = Configuration( + likeListOpenedSource: "stats_post_details", + userProfileSheetShownSource: "stats_post_likes_list", + blogUrlPreviewedSource: "stats_post_likes_list_user_profile" + ) + + static let reader = Configuration( + likeListOpenedSource: "like_reader_list", + userProfileSheetShownSource: "like_reader_list", + blogUrlPreviewedSource: "reader_like_list_user_profile" + ) + } + + private let viewModel: LikesListViewModel + private let configuration: Configuration + private var cancellables = Set() + + // MARK: - Init + + init(viewModel: LikesListViewModel, configuration: Configuration) { + self.viewModel = viewModel + self.configuration = configuration + super.init(rootView: LikesListView(viewModel: viewModel, onSelectUser: { _, _ in })) + + // `self` cannot be captured before `super.init`, so wire the callback now. + rootView = LikesListView(viewModel: viewModel, onSelectUser: { [weak self] user, sourceRect in + self?.displayUserProfile(user, sourceRect: sourceRect) + }) + } + + /// Stats entry point. + convenience init(siteID: NSNumber, postID: NSNumber, totalLikes: Int) { + self.init( + viewModel: LikesListViewModel(siteID: siteID, postID: postID, totalLikes: totalLikes), + configuration: .stats + ) + } + + /// Reader entry point. Fails when the post lacks the IDs needed to fetch likes. + convenience init?(post: ReaderPost) { + guard let viewModel = LikesListViewModel(post: post) else { + return nil + } + self.init(viewModel: viewModel, configuration: .reader) + } + + @MainActor required dynamic init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - View + + override func viewDidLoad() { + super.viewDidLoad() + + // Keep the navigation title in sync with the total likes count. + viewModel.$totalLikes + .sink { [weak self] totalLikes in + self?.updateTitle(totalLikes: totalLikes) + } + .store(in: &cancellables) + + WPAnalytics.track(.likeListOpened, properties: ["list_type": "post", "source": configuration.likeListOpenedSource]) + + viewModel.loadMore() + } + + // MARK: - Helpers + + private func updateTitle(totalLikes: Int) { + let titleFormat = totalLikes == 1 ? TitleFormats.singular : TitleFormats.plural + navigationItem.title = String(format: titleFormat, totalLikes) + } + + private func displayUserProfile(_ user: LikeUser, sourceRect: CGRect) { + let userProfileVC = UserProfileSheetViewController(user: user) + userProfileVC.blogUrlPreviewedSource = configuration.blogUrlPreviewedSource + userProfileVC.modalPresentationStyle = .popover + userProfileVC.popoverPresentationController?.sourceView = view + // Anchor the popover to the tapped row on iPad. `sourceRect` arrives in global + // (window) coordinates; convert it into `view`'s space. On iPhone this adapts to a sheet. + if view.window != nil, sourceRect != .zero { + userProfileVC.popoverPresentationController?.sourceRect = view.convert(sourceRect, from: nil) + } + userProfileVC.popoverPresentationController?.adaptiveSheetPresentationController.prefersGrabberVisible = true + userProfileVC.popoverPresentationController?.adaptiveSheetPresentationController.detents = [.medium()] + present(userProfileVC, animated: true) + + WPAnalytics.track(.userProfileSheetShown, properties: ["source": configuration.userProfileSheetShownSource]) + } + + private enum TitleFormats { + static let singular = NSLocalizedString( + "%1$d Like", + comment: "Singular format string for view title displaying the number of post likes. %1$d is the number of likes." + ) + static let plural = NSLocalizedString( + "%1$d Likes", + comment: "Plural format string for view title displaying the number of post likes. %1$d is the number of likes." + ) + } +} diff --git a/WordPress/Classes/ViewRelated/Likes/LikesListView.swift b/WordPress/Classes/ViewRelated/Likes/LikesListView.swift new file mode 100644 index 000000000000..76450011b093 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Likes/LikesListView.swift @@ -0,0 +1,97 @@ +import SwiftUI +import WordPressData +import WordPressUI + +/// Adaptive grid of users who liked a post, shared by the Stats and Reader likes screens. +/// +/// A single `LazyVGrid` renders both layouts: one flexible column on the compact +/// horizontal size class (visually identical to the previous table) and an adaptive +/// multi-column grid on the regular size class (iPad full screen, split view, Stage Manager). +struct LikesListView: View { + @ObservedObject var viewModel: LikesListViewModel + + /// Presents the tapped user's profile. The `CGRect` is the row's frame in global + /// coordinates, used by the host to anchor the popover on iPad. + let onSelectUser: (LikeUser, CGRect) -> Void + + @Environment(\.horizontalSizeClass) private var horizontalSizeClass + + /// Whether to render the adaptive multi-column grid (regular width) rather than the + /// single full-width column (compact width) that mirrors the previous table. + private var shouldShowGridView: Bool { + horizontalSizeClass == .regular + } + + private var columns: [GridItem] { + if shouldShowGridView { + return [GridItem(.adaptive(minimum: Metrics.minimumColumnWidth), spacing: 0)] + } + return [GridItem(.flexible(), spacing: 0)] + } + + var body: some View { + if let error = viewModel.error { + EmptyStateView( + error.title, + systemImage: "exclamationmark.circle", + description: error.subtitle + ) + } else { + ScrollView { + LazyVGrid(columns: columns, alignment: .leading, spacing: 0) { + ForEach(viewModel.users, id: \.objectID) { user in + LikeUserGridCell( + user: user, + showsDivider: !shouldShowGridView, + onSelect: onSelectUser, + onAppear: { viewModel.loadMoreIfNeeded(displaying: user) } + ) + } + } + + if viewModel.isLoadingPage { + ProgressView() + .frame(maxWidth: .infinity) + .padding() + } + } + } + } + + private enum Metrics { + static let minimumColumnWidth: CGFloat = 300 + } +} + +/// A single tappable liker cell. Using a `Button` gives VoiceOver and Switch Control +/// one labeled control (with the button trait) per row, matching the selectable table +/// row it replaces. The row's global frame is tracked so the host can anchor the profile +/// popover to it on iPad. +private struct LikeUserGridCell: View { + let user: LikeUser + let showsDivider: Bool + let onSelect: (LikeUser, CGRect) -> Void + let onAppear: () -> Void + + @State private var frame: CGRect = .zero + + var body: some View { + Button { + onSelect(user, frame) + } label: { + LikeUserRowView(user: user, showsDivider: showsDivider) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .background( + GeometryReader { proxy in + Color.clear + .onAppear { frame = proxy.frame(in: .global) } + .onChange(of: proxy.frame(in: .global)) { _, newValue in + frame = newValue + } + } + ) + .onAppear(perform: onAppear) + } +} diff --git a/WordPress/Classes/ViewRelated/Likes/LikesListViewModel.swift b/WordPress/Classes/ViewRelated/Likes/LikesListViewModel.swift new file mode 100644 index 000000000000..567934dd9f15 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Likes/LikesListViewModel.swift @@ -0,0 +1,222 @@ +import Combine +import Foundation +import WordPressData +import WordPressKit +import WordPressShared + +/// The subset of `PostService`'s post-likes API the ``LikesListViewModel`` depends on. +/// +/// A protocol seam keeps the view model testable: production uses `PostService`, while +/// tests provide a fake that records the pagination cursor without touching the network. +protocol PostLikesServing: AnyObject { + func likeUsersFor(postID: NSNumber, siteID: NSNumber, after: Date?) -> [LikeUser] + + func getLikesFor(postID: NSNumber, + siteID: NSNumber, + count: Int, + before: String?, + excludingIDs: [NSNumber]?, + purgeExisting: Bool, + success: @escaping (([LikeUser], Int, Int) -> Void), + failure: @escaping ((Error?) -> Void)) +} + +extension PostService: PostLikesServing {} + +/// Drives the shared SwiftUI likes list for the Stats and Reader post-likes screens. +/// +/// This ports the post-likes fetch and pagination engine from `LikesListController`. +/// Comment likes remain Notifications-only and stay behind the legacy controller. +@MainActor +final class LikesListViewModel: ObservableObject { + + struct ErrorViewModel { + let title: String + let subtitle: String? + } + + @Published private(set) var users: [LikeUser] = [] + @Published private(set) var totalLikes: Int + @Published private(set) var isLoadingPage = false + @Published private(set) var error: ErrorViewModel? + + private let siteID: NSNumber + private let postID: NSNumber + private let service: PostLikesServing + + // Pagination state ported from `LikesListController`. + private var isFirstLoad = true + private var totalLikesFetched = 0 + private var lastFetchedDate: String? + private var excludeUserIDs: [NSNumber]? + + private let errorTitle = NSLocalizedString( + "Error loading likes", + comment: "Text displayed when there is a failure loading notification likes." + ) + + /// Whether another page can be fetched. Mirrors `LikesListController.hasMoreLikes`. + var hasMoreLikes: Bool { + return totalLikesFetched < totalLikes + } + + init(siteID: NSNumber, postID: NSNumber, totalLikes: Int, service: PostLikesServing? = nil) { + self.siteID = siteID + self.postID = postID + self.totalLikes = totalLikes + self.service = service ?? PostService(managedObjectContext: ContextManager.shared.mainContext) + } + + convenience init?(post: ReaderPost, service: PostLikesServing? = nil) { + guard let postID = post.postID, let siteID = post.siteID else { + return nil + } + self.init(siteID: siteID, postID: postID, totalLikes: post.likeCount?.intValue ?? 0, service: service) + } + + /// Loads the next page of likes. Called for the initial load and whenever the last + /// row appears. Mirrors `LikesListController.refresh()`. + func loadMore() { + guard !isLoadingPage else { + return + } + + isLoadingPage = true + + if isFirstLoad { + // Seed from Core Data so cached likes render immediately, before the remote refresh. + users = service.likeUsersFor(postID: postID, siteID: siteID, after: nil) + } + + guard ReachabilityUtils.isInternetReachable() else { + isLoadingPage = false + + if users.isEmpty { + error = ErrorViewModel(title: errorTitle, subtitle: nil) + } + + return + } + + let wasFirstLoad = isFirstLoad + let before = modifiedBeforeString() + + service.getLikesFor( + postID: postID, + siteID: siteID, + count: Constants.pageSize, + before: before, + excludingIDs: excludeUserIDs, + purgeExisting: wasFirstLoad, + success: { [weak self] users, totalLikes, likesPerPage in + guard let self else { + return + } + + self.error = nil + self.users = users + self.totalLikes = totalLikes + self.totalLikesFetched = users.count + self.lastFetchedDate = users.last?.dateLikedString + + if !wasFirstLoad && !users.isEmpty { + self.trackFetched(likesPerPage: likesPerPage) + } + + self.isFirstLoad = false + self.isLoadingPage = false + self.trackUsersToExclude() + }, + failure: { [weak self] error in + guard let self else { + return + } + + let subtitle: String? = { + guard let error = error as? NSError, + error.domain == WordPressComRestApiEndpointError.errorDomain, + error.code == WordPressComRestApiErrorCode.authorizationRequired.rawValue else { + return nil + } + return Strings.privateBlogErrorMessage + }() + + self.isLoadingPage = false + self.error = ErrorViewModel(title: self.errorTitle, subtitle: subtitle) + } + ) + } + + /// Loads the next page when the last visible row appears, guarded so we do not + /// re-fetch while a load is in flight or once every like has been retrieved. + func loadMoreIfNeeded(displaying user: LikeUser) { + guard !isLoadingPage, hasMoreLikes, user.objectID == users.last?.objectID else { + return + } + loadMore() + } + + // MARK: - Cursor + + /// The `before` value sent to the endpoint. Uses the last liker's `dateLiked` bumped + /// by one second, formatted "YYYY-MM-DD HH:MM:SS" (the endpoint rejects the T/Z that + /// `ISO8601DateFormatter` emits). Nil until a page has been fetched. + private func modifiedBeforeString() -> String? { + // `lastFetchedDate` only gates whether we page; the actual boundary is the +1s date. + guard lastFetchedDate != nil, let modifiedDate = modifiedBeforeDate() else { + return nil + } + + return ISO8601DateFormatter() + .string(from: modifiedDate) + .replacingMatches(of: "T", with: " ") + .replacingMatches(of: "Z", with: "") + } + + // There is a scenario where multiple users might like a post at the same time and end + // up split between pages. Track which users we already fetched at/after the boundary and + // send those to the endpoint to filter out, so we get neither duplicates nor gaps. + private func trackUsersToExclude() { + guard let modifiedDate = modifiedBeforeDate() else { + return + } + + let fetchedUsers = service.likeUsersFor(postID: postID, siteID: siteID, after: modifiedDate) + excludeUserIDs = fetchedUsers.map { NSNumber(value: $0.userID) } + } + + private func modifiedBeforeDate() -> Date? { + guard let lastDate = users.last?.dateLiked else { + return nil + } + + return Calendar.current.date(byAdding: .second, value: 1, to: lastDate) + } + + // MARK: - Analytics + + private func trackFetched(likesPerPage: Int) { + var properties: [String: Any] = [:] + // The shared engine has always reported "reader" for both Stats and Reader post likes. + properties["source"] = "reader" + properties["per_page"] = likesPerPage + + if likesPerPage > 0 { + properties["page"] = Int(ceil(Double(users.count) / Double(likesPerPage))) + } + + WPAnalytics.track(.likeListFetchedMore, properties: properties) + } + + private enum Constants { + static let pageSize = 90 + } + + private enum Strings { + static let privateBlogErrorMessage = NSLocalizedString( + "likesListViewController.likesList.privateBlogErrorMessage", + value: "You don't have permission to view this private blog.", + comment: "Error message that informs likes from a private blog cannot be fetched." + ) + } +} diff --git a/WordPress/Classes/ViewRelated/Reader/Detail/ReaderDetailCoordinator.swift b/WordPress/Classes/ViewRelated/Reader/Detail/ReaderDetailCoordinator.swift index 65ce64a249d9..9352601d8388 100644 --- a/WordPress/Classes/ViewRelated/Reader/Detail/ReaderDetailCoordinator.swift +++ b/WordPress/Classes/ViewRelated/Reader/Detail/ReaderDetailCoordinator.swift @@ -611,7 +611,9 @@ class ReaderDetailCoordinator { guard let post else { return } - let controller = ReaderDetailLikesListController(post: post, totalLikes: post.likeCount?.intValue ?? 0) + guard let controller = LikesListHostViewController(post: post) else { + return + } viewController?.navigationController?.pushViewController(controller, animated: true) } diff --git a/WordPress/Classes/ViewRelated/Reader/Detail/Views/ReaderDetailLikesListController.swift b/WordPress/Classes/ViewRelated/Reader/Detail/Views/ReaderDetailLikesListController.swift deleted file mode 100644 index 2f8f8e6f310a..000000000000 --- a/WordPress/Classes/ViewRelated/Reader/Detail/Views/ReaderDetailLikesListController.swift +++ /dev/null @@ -1,98 +0,0 @@ -import Foundation -import WordPressData -import WordPressUI -import SwiftUI - -final class ReaderDetailLikesListController: UIViewController { - - // MARK: - Properties - private let tableView = UITableView(frame: .zero, style: .plain) - private let post: ReaderPost - private var likesListController: LikesListController? - private var totalLikes = 0 - - // MARK: - Init - init(post: ReaderPost, totalLikes: Int) { - self.post = post - self.totalLikes = totalLikes - super.init(nibName: nil, bundle: nil) - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - // MARK: - View - override func viewDidLoad() { - super.viewDidLoad() - - configureViewTitle() - configureTableView() - WPAnalytics.track(.likeListOpened, properties: ["list_type": "post", "source": "like_reader_list"]) - } -} - -private extension ReaderDetailLikesListController { - - func configureViewTitle() { - let titleFormat = totalLikes == 1 ? TitleFormats.singular : TitleFormats.plural - navigationItem.title = String(format: titleFormat, totalLikes) - } - - func configureTableView() { - view.addSubview(tableView) - tableView.pinEdges() - - tableView.register(LikeUserTableViewCell.defaultNib, - forCellReuseIdentifier: LikeUserTableViewCell.defaultReuseID) - - likesListController = LikesListController(tableView: tableView, post: post, delegate: self) - tableView.delegate = likesListController - tableView.dataSource = likesListController - - // The separator is controlled by LikeUserTableViewCell - tableView.separatorStyle = .none - - // Call refresh to ensure that the controller fetches the data. - likesListController?.refresh() - } - - func displayUserProfile(_ user: LikeUser, from indexPath: IndexPath) { - let userProfileVC = UserProfileSheetViewController(user: user) - userProfileVC.blogUrlPreviewedSource = "reader_like_list_user_profile" - userProfileVC.modalPresentationStyle = .popover - userProfileVC.popoverPresentationController?.sourceView = tableView.cellForRow(at: indexPath) ?? view - userProfileVC.popoverPresentationController?.adaptiveSheetPresentationController.prefersGrabberVisible = true - userProfileVC.popoverPresentationController?.adaptiveSheetPresentationController.detents = [.medium()] - present(userProfileVC, animated: true) - - WPAnalytics.track(.userProfileSheetShown, properties: ["source": "like_reader_list"]) - } - - struct TitleFormats { - static let singular = NSLocalizedString("%1$d Like", - comment: "Singular format string for view title displaying the number of post likes. %1$d is the number of likes.") - static let plural = NSLocalizedString("%1$d Likes", - comment: "Plural format string for view title displaying the number of post likes. %1$d is the number of likes.") - } -} - -// MARK: - LikesListController Delegate -// -extension ReaderDetailLikesListController: LikesListControllerDelegate { - - func didSelectUser(_ user: LikeUser, at indexPath: IndexPath) { - displayUserProfile(user, from: indexPath) - } - - func showErrorView(title: String, subtitle: String?) { - let stateView = UIHostingView(view: EmptyStateView(title, systemImage: "exclamationmark.circle", description: subtitle)) - view.addSubview(stateView) - stateView.pinEdges() - } - - func updatedTotalLikes(_ totalLikes: Int) { - self.totalLikes = totalLikes - configureViewTitle() - } -} diff --git a/WordPress/Classes/ViewRelated/Stats/StatsHostingViewController.swift b/WordPress/Classes/ViewRelated/Stats/StatsHostingViewController.swift index 74aebd4f00ad..e58a9e6e8341 100644 --- a/WordPress/Classes/ViewRelated/Stats/StatsHostingViewController.swift +++ b/WordPress/Classes/ViewRelated/Stats/StatsHostingViewController.swift @@ -101,7 +101,7 @@ extension StatsRouter { /// Shared router implementation for Jetpack app stats navigation private final class JetpackAppStatsRouterScreenFactory: StatsRouterScreenFactory { func makeLikesListViewController(siteID: Int, postID: Int, totalLikes: Int) -> UIViewController { - StatsLikesListViewController( + LikesListHostViewController( siteID: siteID as NSNumber, postID: NSNumber(value: postID), totalLikes: totalLikes diff --git a/WordPress/Classes/ViewRelated/Stats/StatsLikesListViewController.swift b/WordPress/Classes/ViewRelated/Stats/StatsLikesListViewController.swift deleted file mode 100644 index 7567964e62e3..000000000000 --- a/WordPress/Classes/ViewRelated/Stats/StatsLikesListViewController.swift +++ /dev/null @@ -1,106 +0,0 @@ -import Foundation -import UIKit -import WordPressData -import WordPressUI - -/// A view controller that displays the list of users who liked a post from the Stats screen. -class StatsLikesListViewController: UIViewController { - - // MARK: - Properties - private let tableView = UITableView(frame: .zero, style: .plain) - private let siteID: NSNumber - private let postID: NSNumber - private var likesListController: LikesListController? - private var totalLikes: Int - - // MARK: - Init - init(siteID: NSNumber, postID: NSNumber, totalLikes: Int) { - self.siteID = siteID - self.postID = postID - self.totalLikes = totalLikes - super.init(nibName: nil, bundle: nil) - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - // MARK: - View - override func viewDidLoad() { - super.viewDidLoad() - - configureViewTitle() - configureTableView() - WPAnalytics.track(.likeListOpened, properties: ["list_type": "post", "source": "stats_post_details"]) - } -} - -private extension StatsLikesListViewController { - - func configureViewTitle() { - let titleFormat = totalLikes == 1 ? TitleFormats.singular : TitleFormats.plural - navigationItem.title = String(format: titleFormat, totalLikes) - } - - func configureTableView() { - view.addSubview(tableView) - tableView.pinEdges() - - tableView.register(LikeUserTableViewCell.defaultNib, - forCellReuseIdentifier: LikeUserTableViewCell.defaultReuseID) - - likesListController = LikesListController( - tableView: tableView, - siteID: siteID, - postID: postID, - delegate: self - ) - tableView.delegate = likesListController - tableView.dataSource = likesListController - - // The separator is controlled by LikeUserTableViewCell - tableView.separatorStyle = .none - - // Call refresh to ensure that the controller fetches the data. - likesListController?.refresh() - } - - func displayUserProfile(_ user: LikeUser, from indexPath: IndexPath) { - let userProfileVC = UserProfileSheetViewController(user: user) - userProfileVC.blogUrlPreviewedSource = "stats_post_likes_list_user_profile" - userProfileVC.modalPresentationStyle = .popover - userProfileVC.popoverPresentationController?.sourceView = tableView.cellForRow(at: indexPath) ?? view - userProfileVC.popoverPresentationController?.adaptiveSheetPresentationController.prefersGrabberVisible = true - userProfileVC.popoverPresentationController?.adaptiveSheetPresentationController.detents = [.medium()] - present(userProfileVC, animated: true) - - WPAnalytics.track(.userProfileSheetShown, properties: ["source": "stats_post_likes_list"]) - } - - struct TitleFormats { - static let singular = NSLocalizedString("%1$d Like", - comment: "Singular format string for view title displaying the number of post likes. %1$d is the number of likes.") - static let plural = NSLocalizedString("%1$d Likes", - comment: "Plural format string for view title displaying the number of post likes. %1$d is the number of likes.") - } -} - -// MARK: - LikesListController Delegate -// -extension StatsLikesListViewController: LikesListControllerDelegate { - - func didSelectUser(_ user: LikeUser, at indexPath: IndexPath) { - displayUserProfile(user, from: indexPath) - } - - func showErrorView(title: String, subtitle: String?) { - let stateView = UIHostingView(view: EmptyStateView(title, systemImage: "exclamationmark.circle", description: subtitle)) - view.addSubview(stateView) - stateView.pinEdges() - } - - func updatedTotalLikes(_ totalLikes: Int) { - self.totalLikes = totalLikes - configureViewTitle() - } -}