diff --git a/Podfile b/Podfile index 410db8783eae..a5dda3b97bfc 100644 --- a/Podfile +++ b/Podfile @@ -56,7 +56,7 @@ end def wordpress_kit # pod 'WordPressKit', '~> 17.0.0' - pod 'WordPressKit', git: 'https://github.com/wordpress-mobile/WordPressKit-iOS.git', commit: '1a0955f6cbc20b258f82be7887d6e8e56a6fbce3' + pod 'WordPressKit', git: 'https://github.com/wordpress-mobile/WordPressKit-iOS.git', commit: '14aa53a2e1cfa764e3e9e3e91d1f39f4ef09e098' # pod 'WordPressKit', git: 'https://github.com/wordpress-mobile/WordPressKit-iOS.git', branch: '' # pod 'WordPressKit', git: 'https://github.com/wordpress-mobile/WordPressKit-iOS.git', tag: '' # pod 'WordPressKit', path: '../WordPressKit-iOS' diff --git a/Podfile.lock b/Podfile.lock index d1558270f73c..84ba5bea8268 100644 --- a/Podfile.lock +++ b/Podfile.lock @@ -121,7 +121,7 @@ DEPENDENCIES: - SwiftLint (= 0.54.0) - WordPress-Editor-iOS (~> 1.19.11) - WordPressAuthenticator (>= 9.0.8, ~> 9.0) - - WordPressKit (from `https://github.com/wordpress-mobile/WordPressKit-iOS.git`, commit `1a0955f6cbc20b258f82be7887d6e8e56a6fbce3`) + - WordPressKit (from `https://github.com/wordpress-mobile/WordPressKit-iOS.git`, commit `14aa53a2e1cfa764e3e9e3e91d1f39f4ef09e098`) - WordPressShared (from `https://github.com/wordpress-mobile/WordPress-iOS-Shared.git`, commit `688ee5e4efddc1fc23626626ef17b7e929bdafb0`) - WordPressUI (~> 1.16) - ZendeskSupportSDK (= 5.3.0) @@ -177,7 +177,7 @@ EXTERNAL SOURCES: Gutenberg: :podspec: https://cdn.a8c-ci.services/gutenberg-mobile/Gutenberg-v1.117.0.podspec WordPressKit: - :commit: 1a0955f6cbc20b258f82be7887d6e8e56a6fbce3 + :commit: 14aa53a2e1cfa764e3e9e3e91d1f39f4ef09e098 :git: https://github.com/wordpress-mobile/WordPressKit-iOS.git WordPressShared: :commit: 688ee5e4efddc1fc23626626ef17b7e929bdafb0 @@ -188,7 +188,7 @@ CHECKOUT OPTIONS: :git: https://github.com/wordpress-mobile/FSInteractiveMap.git :tag: 0.2.0 WordPressKit: - :commit: 1a0955f6cbc20b258f82be7887d6e8e56a6fbce3 + :commit: 14aa53a2e1cfa764e3e9e3e91d1f39f4ef09e098 :git: https://github.com/wordpress-mobile/WordPressKit-iOS.git WordPressShared: :commit: 688ee5e4efddc1fc23626626ef17b7e929bdafb0 @@ -239,6 +239,6 @@ SPEC CHECKSUMS: ZendeskSupportSDK: 3a8e508ab1d9dd22dc038df6c694466414e037ba ZIPFoundation: d170fa8e270b2a32bef9dcdcabff5b8f1a5deced -PODFILE CHECKSUM: 2c841e7c9f39bc169a4b75accb5076d2b31e5468 +PODFILE CHECKSUM: 4ac1d35f8415bdc8d4c8e39d6aa7a9f6dab5933d COCOAPODS: 1.15.2 diff --git a/WordPress/Classes/ViewRelated/Stats/Helpers/StatSection.swift b/WordPress/Classes/ViewRelated/Stats/Helpers/StatSection.swift index 0eb08d3d1a72..6c0c00c7ab24 100644 --- a/WordPress/Classes/ViewRelated/Stats/Helpers/StatSection.swift +++ b/WordPress/Classes/ViewRelated/Stats/Helpers/StatSection.swift @@ -35,6 +35,7 @@ case postStatsAverageViews case postStatsRecentWeeks case subscribersEmailsSummary + case subscribersList static let allInsights: [StatSection] = [ .insightsViewsVisitors, @@ -108,7 +109,7 @@ return InsightsHeaders.comments } case .insightsFollowersWordPress, .insightsFollowersEmail: - return InsightsHeaders.followers + return InsightsHeaders.subscribers case .insightsTodaysStats: return InsightsHeaders.todaysStats case .insightsPostingActivity: @@ -145,6 +146,8 @@ return PostStatsHeaders.recentWeeks case .subscribersEmailsSummary: return SubscribersHeaders.emailsSummaryStats + case .subscribersList: + return SubscribersHeaders.subscribersList default: return "" } @@ -164,7 +167,7 @@ return ItemSubtitles.service case .insightsFollowersWordPress, .insightsFollowersEmail: - return ItemSubtitles.follower + return ItemSubtitles.subscriber case .periodReferrers: return ItemSubtitles.referrer case .periodClicks: @@ -402,7 +405,7 @@ static let posts = NSLocalizedString("Posts", comment: "Insights 'Posts' header") static let comments = NSLocalizedString("Comments", comment: "Insights 'Comments' header") static let topCommenters = NSLocalizedString("Top Commenters", comment: "Insights 'Top Commenters' header") - static let followers = NSLocalizedString("Followers", comment: "Insights 'Followers' header") + static let subscribers = NSLocalizedString("stats.insights.subscribers.title", value: "Subscribers", comment: "Insights 'Subscribers' header") static let tagsAndCategories = NSLocalizedString("Tags and Categories", comment: "Insights 'Tags and Categories' header") static let annualSiteStats = NSLocalizedString("This Year", comment: "Insights 'This Year' header") static let addCard = NSLocalizedString("Add stats card", comment: "Label for action to add a new Insight.") @@ -431,6 +434,7 @@ struct SubscribersHeaders { static let emailsSummaryStats = NSLocalizedString("stats.subscribers.emailsSummaryCard.title", value: "Emails", comment: "Stats 'Emails' card header") + static let subscribersList = NSLocalizedString("stats.subscribers.subscribersListCard.title", value: "Subscribers", comment: "Stats 'Subscribers' card header") } struct PostStatsHeaders { @@ -443,7 +447,7 @@ static let author = NSLocalizedString("Author", comment: "Label for list of stats by content author.") static let title = NSLocalizedString("Title", comment: "Label for list of stats by content title.") static let service = NSLocalizedString("Service", comment: "Label for connected service in Publicize stat.") - static let follower = NSLocalizedString("Follower", comment: "Label for list of followers.") + static let subscriber = NSLocalizedString("stats.section.itemSubtitles.subscriber", value: "Name", comment: "Table column title that shows the names of subscribers.") static let referrer = NSLocalizedString("Referrer", comment: "Label for link title in Referrers stat.") static let link = NSLocalizedString("Link", comment: "Label for link title in Clicks stat.") static let country = NSLocalizedString("Country", comment: "Label for list of countries.") @@ -457,7 +461,7 @@ static let comments = NSLocalizedString("Comments", comment: "Label for number of comments.") static let views = NSLocalizedString("Views", comment: "Label for number of views.") static let followers = NSLocalizedString("Followers", comment: "Label for number of followers.") - static let since = NSLocalizedString("Since", comment: "Label for time period in list of followers.") + static let since = NSLocalizedString("stats.section.dataSubtitles.subscriberSince", value: "Subscriber since", comment: "Table column title that shows the date since the user became a subscriber.") static let clicks = NSLocalizedString("Clicks", comment: "Label for number of clicks.") static let downloads = NSLocalizedString("Downloads", comment: "Label for number of file downloads.") static let emailsSummaryOpens = NSLocalizedString("stats.subscribers.emailsSummary.column.opens", value: "Opens", comment: "A title for table's column that shows a number of email openings") diff --git a/WordPress/Classes/ViewRelated/Stats/Subscribers/StatsSubscribersCache.swift b/WordPress/Classes/ViewRelated/Stats/Subscribers/StatsSubscribersCache.swift index db11cb0a3c51..850b04af9197 100644 --- a/WordPress/Classes/ViewRelated/Stats/Subscribers/StatsSubscribersCache.swift +++ b/WordPress/Classes/ViewRelated/Stats/Subscribers/StatsSubscribersCache.swift @@ -26,5 +26,9 @@ final class StatsSubscribersCache { static func emailsSummary(quantity: Int, sortField: String, sortOrder: String, siteId: NSNumber) -> CacheKey { return .init(record: .subscribersEmailsSummary, key: "\(quantity) \(sortField) \(sortOrder)", siteID: siteId) } + + static func subscribersList(quantity: Int, siteId: NSNumber) -> CacheKey { + return .init(record: .subscribersList, key: "\(quantity)", siteID: siteId) + } } } diff --git a/WordPress/Classes/ViewRelated/Stats/Subscribers/StatsSubscribersStore.swift b/WordPress/Classes/ViewRelated/Stats/Subscribers/StatsSubscribersStore.swift index 1fe9714019aa..119e6b9da308 100644 --- a/WordPress/Classes/ViewRelated/Stats/Subscribers/StatsSubscribersStore.swift +++ b/WordPress/Classes/ViewRelated/Stats/Subscribers/StatsSubscribersStore.swift @@ -4,7 +4,10 @@ import WordPressKit protocol StatsSubscribersStoreProtocol { var emailsSummary: CurrentValueSubject, Never> { get } + var subscribersList: CurrentValueSubject, Never> { get } + func updateEmailsSummary(quantity: Int, sortField: StatsEmailsSummaryData.SortField) + func updateSubscribersList(quantity: Int) } struct StatsSubscribersStore: StatsSubscribersStoreProtocol { @@ -13,6 +16,7 @@ struct StatsSubscribersStore: StatsSubscribersStoreProtocol { private let statsService: StatsServiceRemoteV2 var emailsSummary: CurrentValueSubject, Never> = .init(.idle) + var subscribersList: CurrentValueSubject, Never> = .init(.idle) init() { self.siteID = SiteStatsInformation.sharedInstance.siteID ?? 0 @@ -21,6 +25,8 @@ struct StatsSubscribersStore: StatsSubscribersStoreProtocol { statsService = StatsServiceRemoteV2(wordPressComRestApi: wpApi, siteID: siteID.intValue, siteTimezone: timeZone) } + // MARK: - Emails Summary + func updateEmailsSummary(quantity: Int, sortField: StatsEmailsSummaryData.SortField) { guard emailsSummary.value != .loading else { return } @@ -48,6 +54,67 @@ struct StatsSubscribersStore: StatsSubscribersStoreProtocol { } } } + + // MARK: - Subscribers List + + func updateSubscribersList(quantity: Int) { + let cacheKey = StatsSubscribersCache.CacheKey.subscribersList(quantity: quantity, siteId: siteID) + let cachedData: [StatsFollower]? = cache.getValue(key: cacheKey) + + if let cachedData = cachedData { + self.subscribersList.send(.success(cachedData)) + } else { + subscribersList.send(.loading) + } + + getSubscribers(quantity: quantity) { result in + DispatchQueue.main.async { + switch result { + case .success(let data): + cache.setValue(data, key: cacheKey) + self.subscribersList.send(.success(data)) + case .failure: + if cachedData == nil { + self.subscribersList.send(.error) + } + } + } + } + } + + private func getSubscribers(quantity: Int, completion: @escaping (Result<[StatsFollower], Error>) -> Void) { + let group = DispatchGroup() + var followers: [StatsFollower] = [] + var requestError: Error? + + group.enter() + statsService.getInsight(limit: quantity) { (wpComFollowers: StatsDotComFollowersInsight?, error) in + followers.append(contentsOf: wpComFollowers?.topDotComFollowers ?? []) + requestError = error + group.leave() + } + + group.enter() + statsService.getInsight(limit: quantity) { (wpComFollowers: StatsEmailFollowersInsight?, error) in + followers.append(contentsOf: wpComFollowers?.topEmailFollowers ?? []) + requestError = error + group.leave() + } + + group.notify(queue: .main) { + if let error = requestError { + completion(.failure(error)) + } else { + // Combine both wpcom and email subscribers into a single list + let followers = Array( + followers + .sorted(by: { $0.subscribedDate > $1.subscribedDate }) + .prefix(quantity) + ) + completion(.success(followers)) + } + } + } } extension StatsSubscribersStore { diff --git a/WordPress/Classes/ViewRelated/Stats/Subscribers/StatsSubscribersViewModel.swift b/WordPress/Classes/ViewRelated/Stats/Subscribers/StatsSubscribersViewModel.swift index 76bb4138d665..bfd93b4a5a36 100644 --- a/WordPress/Classes/ViewRelated/Stats/Subscribers/StatsSubscribersViewModel.swift +++ b/WordPress/Classes/ViewRelated/Stats/Subscribers/StatsSubscribersViewModel.swift @@ -15,17 +15,20 @@ final class StatsSubscribersViewModel { func refreshData() { store.updateEmailsSummary(quantity: 10, sortField: .postId) + store.updateSubscribersList(quantity: 10) } // MARK: - Lifecycle func addObservers() { - Publishers.MergeMany(store.emailsSummary) - .removeDuplicates() - .sink { [weak self] _ in - self?.updateTableViewSnapshot() - } - .store(in: &cancellables) + Publishers.CombineLatest( + store.emailsSummary.removeDuplicates(), + store.subscribersList.removeDuplicates() + ) + .sink { [weak self] _ in + self?.updateTableViewSnapshot() + } + .store(in: &cancellables) } func removeObservers() { @@ -37,9 +40,9 @@ final class StatsSubscribersViewModel { private extension StatsSubscribersViewModel { func updateTableViewSnapshot() { - let snapshot = ImmuTableDiffableDataSourceSnapshot.multiSectionSnapshot( - emailsSummaryRows() - ) + var snapshot = ImmuTableDiffableDataSourceSnapshot() + snapshot.addSection(subscribersListRows()) + snapshot.addSection(emailsSummaryRows()) tableViewSnapshot.send(snapshot) } @@ -87,3 +90,37 @@ private extension StatsSubscribersViewModel { } } } + +// MARK: - Subscribers List + +private extension StatsSubscribersViewModel { + func subscribersListRows() -> [any StatsHashableImmuTableRow] { + switch store.subscribersList.value { + case .loading, .idle: + return loadingRows(.subscribersList) + case .success(let subscribers): + return [ + TopTotalsPeriodStatsRow( + itemSubtitle: StatSection.ItemSubtitles.subscriber, + dataSubtitle: StatSection.DataSubtitles.since, + dataRows: subscribersListDataRows(subscribers), + statSection: .subscribersList, + siteStatsPeriodDelegate: viewMoreDelegate + ) + ] + case .error: + return errorRows(.subscribersList) + } + } + + func subscribersListDataRows(_ subscribers: [StatsFollower]) -> [StatsTotalRowData] { + return subscribers.map { + return StatsTotalRowData( + name: $0.name, + data: $0.subscribedDate.relativeStringInPast(), + userIconURL: $0.avatarURL, + statSection: .subscribersList + ) + } + } +} diff --git a/WordPress/WordPressTest/StatsSubscribersViewModelTests.swift b/WordPress/WordPressTest/StatsSubscribersViewModelTests.swift index 1b3edcc3c382..b31ef5ea41db 100644 --- a/WordPress/WordPressTest/StatsSubscribersViewModelTests.swift +++ b/WordPress/WordPressTest/StatsSubscribersViewModelTests.swift @@ -29,12 +29,37 @@ final class StatsSubscribersViewModelTests: XCTestCase { wait(for: [expectation], timeout: 1) } + func testTableViewSnapshot_subscribersListLoaded() throws { + let expectation = expectation(description: "First section should be TopTotalsPeriodStatsRow") + var subscribersListRow: TopTotalsPeriodStatsRow? + sut.tableViewSnapshot + .sink(receiveValue: { snapshot in + if let row = snapshot.itemIdentifiers[0].immuTableRow as? TopTotalsPeriodStatsRow { + subscribersListRow = row + expectation.fulfill() + } + }) + .store(in: &cancellables) + + let subscribers: [StatsFollower] = [ + .init(name: "First Subscriber", subscribedDate: Date(), avatarURL: nil), + .init(name: "Second Subscriber", subscribedDate: Date(), avatarURL: nil), + .init(name: "Third Subscriber", subscribedDate: Date(), avatarURL: nil) + ] + store.subscribersList.send(.success(subscribers)) + + wait(for: [expectation], timeout: 1) + XCTAssertEqual(subscribersListRow?.dataRows.count, 3) + XCTAssertEqual(subscribersListRow?.dataRows[0].name, "First Subscriber") + XCTAssertEqual(subscribersListRow?.dataRows[2].name, "Third Subscriber") + } + func testTableViewSnapshot_emailsSummaryLoaded() throws { - let expectation = expectation(description: "First section should be loading") + let expectation = expectation(description: "First section should be TopTotalsPeriodStatsRow") var emailsSummaryRow: TopTotalsPeriodStatsRow? sut.tableViewSnapshot .sink(receiveValue: { snapshot in - if let row = snapshot.itemIdentifiers.first?.immuTableRow as? TopTotalsPeriodStatsRow { + if let row = snapshot.itemIdentifiers[1].immuTableRow as? TopTotalsPeriodStatsRow { emailsSummaryRow = row expectation.fulfill() } @@ -57,9 +82,15 @@ final class StatsSubscribersViewModelTests: XCTestCase { private class StatsSubscribersStoreMock: StatsSubscribersStoreProtocol { var emailsSummary: CurrentValueSubject, Never> = .init(.idle) + var subscribersList: CurrentValueSubject, Never> = .init(.idle) var updateEmailsSummaryCalled = false + var updateSubscribersListCalled = false func updateEmailsSummary(quantity: Int, sortField: StatsEmailsSummaryData.SortField) { updateEmailsSummaryCalled = true } + + func updateSubscribersList(quantity: Int) { + updateSubscribersListCalled = true + } }