From 7379b0d8adfe1c73169aca5112cfa78fcf478d94 Mon Sep 17 00:00:00 2001 From: mormaer Date: Sat, 26 Aug 2023 11:08:22 +0100 Subject: [PATCH] Refactor - `CommunityListView` and add test coverage (#525) --- Mlem.xcodeproj/project.pbxproj | 24 ++ Mlem/API/APIClient.swift | 4 +- Mlem/Dependency/APIClient+Dependency.swift | 2 +- ...avoriteCommunitiesTracker+Dependency.swift | 21 ++ Mlem/Dependency/Notifier+Dependency.swift | 2 +- Mlem/Extensions/String.swift | 6 + .../View - Handle Lemmy Links.swift | 5 - .../Trackers/Favorite Community Tracker.swift | 11 +- Mlem/Notifications/Notifier.swift | 8 +- .../Community List/Community List View.swift | 249 ++----------- .../Community List/CommunityListModel.swift | 239 +++++++++++++ .../Components/CommunityListRowViews.swift | 43 +-- .../CommunityListSidebarEntry.swift | 12 +- .../Components/SectionIndexTitles.swift | 87 +++++ Mlem/Views/Tabs/Feeds/Feed Root.swift | 2 +- Mlem/Views/Tabs/Feeds/Feed View.swift | 2 +- .../Views/General/GeneralSettingsView.swift | 13 +- Mlem/Window.swift | 4 +- .../CommunityListModelTests.swift | 334 ++++++++++++++++++ 19 files changed, 785 insertions(+), 283 deletions(-) create mode 100644 Mlem/Dependency/FavoriteCommunitiesTracker+Dependency.swift create mode 100644 Mlem/Views/Tabs/Feeds/Community List/CommunityListModel.swift create mode 100644 Mlem/Views/Tabs/Feeds/Community List/Components/SectionIndexTitles.swift create mode 100644 MlemTests/Community List/CommunityListModelTests.swift diff --git a/Mlem.xcodeproj/project.pbxproj b/Mlem.xcodeproj/project.pbxproj index ed50d4b04..ceffe2dd6 100644 --- a/Mlem.xcodeproj/project.pbxproj +++ b/Mlem.xcodeproj/project.pbxproj @@ -31,6 +31,9 @@ 503A5D752A78EF3C00488C38 /* Encodable+Export.swift in Sources */ = {isa = PBXBuildFile; fileRef = 503A5D742A78EF3C00488C38 /* Encodable+Export.swift */; }; 503BA26F2A2C94540052516C /* URL+Identifiable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 503BA26E2A2C94540052516C /* URL+Identifiable.swift */; }; 504106CD2A744D7F000AAEF8 /* CommentRepository+Dependency.swift in Sources */ = {isa = PBXBuildFile; fileRef = 504106CC2A744D7F000AAEF8 /* CommentRepository+Dependency.swift */; }; + 505240E32A86916500EA4558 /* FavoriteCommunitiesTracker+Dependency.swift in Sources */ = {isa = PBXBuildFile; fileRef = 505240E22A86916500EA4558 /* FavoriteCommunitiesTracker+Dependency.swift */; }; + 505240E52A86E32700EA4558 /* CommunityListModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 505240E42A86E32700EA4558 /* CommunityListModel.swift */; }; + 505240E72A88D36D00EA4558 /* SectionIndexTitles.swift in Sources */ = {isa = PBXBuildFile; fileRef = 505240E62A88D36D00EA4558 /* SectionIndexTitles.swift */; }; 5064D03D2A6DE0AA00B22EE3 /* Notifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5064D03C2A6DE0AA00B22EE3 /* Notifier.swift */; }; 5064D03F2A6DE0DB00B22EE3 /* Notifier+Dependency.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5064D03E2A6DE0DB00B22EE3 /* Notifier+Dependency.swift */; }; 5064D0412A6E63E000B22EE3 /* Task+Notifiable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5064D0402A6E63E000B22EE3 /* Task+Notifiable.swift */; }; @@ -63,6 +66,7 @@ 50A8812C2A72D727003E3661 /* CommunityRepository+Dependency.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50A8812B2A72D727003E3661 /* CommunityRepository+Dependency.swift */; }; 50A8812E2A72D76C003E3661 /* APIClient+Comment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50A8812D2A72D76C003E3661 /* APIClient+Comment.swift */; }; 50BC1ABB2A8D6A5A00E3C48B /* ScoringOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50BC1ABA2A8D6A5A00E3C48B /* ScoringOperation.swift */; }; + 50BC1AB92A89744200E3C48B /* CommunityListModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50BC1AB82A89744200E3C48B /* CommunityListModelTests.swift */; }; 50C86ABC2A7E50E200277519 /* PersistenceRepositoryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50C86ABB2A7E50E200277519 /* PersistenceRepositoryTests.swift */; }; 50C99B562A61D792005D57DD /* Dependencies in Frameworks */ = {isa = PBXBuildFile; productRef = 50C99B552A61D792005D57DD /* Dependencies */; }; 50C99B592A61D889005D57DD /* APIClient+Dependency.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50C99B582A61D889005D57DD /* APIClient+Dependency.swift */; }; @@ -438,6 +442,9 @@ 503A5D742A78EF3C00488C38 /* Encodable+Export.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Encodable+Export.swift"; sourceTree = ""; }; 503BA26E2A2C94540052516C /* URL+Identifiable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "URL+Identifiable.swift"; sourceTree = ""; }; 504106CC2A744D7F000AAEF8 /* CommentRepository+Dependency.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "CommentRepository+Dependency.swift"; sourceTree = ""; }; + 505240E22A86916500EA4558 /* FavoriteCommunitiesTracker+Dependency.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FavoriteCommunitiesTracker+Dependency.swift"; sourceTree = ""; }; + 505240E42A86E32700EA4558 /* CommunityListModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommunityListModel.swift; sourceTree = ""; }; + 505240E62A88D36D00EA4558 /* SectionIndexTitles.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SectionIndexTitles.swift; sourceTree = ""; }; 5064D03C2A6DE0AA00B22EE3 /* Notifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Notifier.swift; sourceTree = ""; }; 5064D03E2A6DE0DB00B22EE3 /* Notifier+Dependency.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Notifier+Dependency.swift"; sourceTree = ""; }; 5064D0402A6E63E000B22EE3 /* Task+Notifiable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Task+Notifiable.swift"; sourceTree = ""; }; @@ -470,6 +477,7 @@ 50A8812B2A72D727003E3661 /* CommunityRepository+Dependency.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CommunityRepository+Dependency.swift"; sourceTree = ""; }; 50A8812D2A72D76C003E3661 /* APIClient+Comment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIClient+Comment.swift"; sourceTree = ""; }; 50BC1ABA2A8D6A5A00E3C48B /* ScoringOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScoringOperation.swift; sourceTree = ""; }; + 50BC1AB82A89744200E3C48B /* CommunityListModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommunityListModelTests.swift; sourceTree = ""; }; 50C86ABB2A7E50E200277519 /* PersistenceRepositoryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistenceRepositoryTests.swift; sourceTree = ""; }; 50C99B582A61D889005D57DD /* APIClient+Dependency.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIClient+Dependency.swift"; sourceTree = ""; }; 50C99B5B2A61F5EB005D57DD /* CommentRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommentRepository.swift; sourceTree = ""; }; @@ -1010,6 +1018,14 @@ path = Mocks; sourceTree = ""; }; + 50BC1AB72A89741000E3C48B /* Community List */ = { + isa = PBXGroup; + children = ( + 50BC1AB82A89744200E3C48B /* CommunityListModelTests.swift */, + ); + path = "Community List"; + sourceTree = ""; + }; 50C86AB82A7E507200277519 /* Persistence */ = { isa = PBXGroup; children = ( @@ -1027,6 +1043,7 @@ 500C168D2A66FAAB006F243B /* HapticManager+Dependency.swift */, 5064D03E2A6DE0DB00B22EE3 /* Notifier+Dependency.swift */, 50785F722A98E03F00117245 /* SiteInformationTracker+Dependency.swift */, + 505240E22A86916500EA4558 /* FavoriteCommunitiesTracker+Dependency.swift */, ); path = Dependency; sourceTree = ""; @@ -1221,6 +1238,7 @@ isa = PBXGroup; children = ( 6D8F08FE2A4029AE003EB4FD /* Community List View.swift */, + 505240E42A86E32700EA4558 /* CommunityListModel.swift */, 6D91D4532A41597B006B8F9A /* Components */, ); path = "Community List"; @@ -1334,6 +1352,7 @@ 6363D5D927EE196A00E34822 /* MlemTests */ = { isa = PBXGroup; children = ( + 50BC1AB72A89741000E3C48B /* Community List */, 50DBB8DE2A805770002870B1 /* Mocks */, 50C86AB82A7E507200277519 /* Persistence */, 6363D5DA27EE196A00E34822 /* MlemTests.swift */, @@ -1642,6 +1661,7 @@ children = ( 6D91D4542A415994006B8F9A /* CommunityListSidebarEntry.swift */, 6D91D4572A4159D8006B8F9A /* CommunityListRowViews.swift */, + 505240E62A88D36D00EA4558 /* SectionIndexTitles.swift */, ); path = Components; sourceTree = ""; @@ -2346,6 +2366,7 @@ CD1446232A5B336900610EF1 /* LicensesView.swift in Sources */, CDDCF6432A66343D003DA3AC /* FancyTabBar.swift in Sources */, 6318DE5627FBAE3600CC2AD6 /* Share Sheet.swift in Sources */, + 505240E52A86E32700EA4558 /* CommunityListModel.swift in Sources */, CD05E77F2A4F263B0081D102 /* Menu Function.swift in Sources */, CDDB08782A5DF1330075BFEE /* CommentSettingsView.swift in Sources */, 6386E02C2A03D1EC006B3C1D /* App State.swift in Sources */, @@ -2382,6 +2403,7 @@ CD05E7792A4E381A0081D102 /* PostSize.swift in Sources */, 6D7782362A48EED8008AC1BF /* APIPrivateMessage.swift in Sources */, CDE3BA892A8C64BD00B972E2 /* Collapsible Text Item.swift in Sources */, + 505240E72A88D36D00EA4558 /* SectionIndexTitles.swift in Sources */, 5064D0452A71549C00B22EE3 /* NotificationMessage.swift in Sources */, 63344C4D2A07ABEE001BC616 /* Community.swift in Sources */, CDF842612A49EA3900723DA0 /* Mentions Tracker.swift in Sources */, @@ -2477,6 +2499,7 @@ 6372186D2A3A2AAD008C4816 /* EditComment.swift in Sources */, 6D693A4A2A51B98F009E2D76 /* APICommentReportView.swift in Sources */, 637218622A3A2AAD008C4816 /* DeletePost.swift in Sources */, + 505240E32A86916500EA4558 /* FavoriteCommunitiesTracker+Dependency.swift in Sources */, 6332FDC327EFCB5F0009A98A /* Color.swift in Sources */, 637218432A3A2AAD008C4816 /* APIClient.swift in Sources */, CD82A2572A716D7C00111034 /* PersonRepository+Dependency.swift in Sources */, @@ -2613,6 +2636,7 @@ files = ( 50DBB8E02A805836002870B1 /* MockErrorHandler.swift in Sources */, 6363D5DB27EE196A00E34822 /* MlemTests.swift in Sources */, + 50BC1AB92A89744200E3C48B /* CommunityListModelTests.swift in Sources */, 50DBB8E22A80F9E4002870B1 /* APICommunity+Mock.swift in Sources */, 50C86ABC2A7E50E200277519 /* PersistenceRepositoryTests.swift in Sources */, ); diff --git a/Mlem/API/APIClient.swift b/Mlem/API/APIClient.swift index 58d8f870c..f75022b5b 100644 --- a/Mlem/API/APIClient.swift +++ b/Mlem/API/APIClient.swift @@ -44,11 +44,11 @@ class APIClient { // MARK: - Initialisation init( - session: URLSession = .init(configuration: .default), + urlSession: URLSession = .init(configuration: .default), decoder: JSONDecoder = .defaultDecoder, transport: @escaping (URLSession, URLRequest) async throws -> (Data, URLResponse) ) { - self.urlSession = session + self.urlSession = urlSession self.decoder = decoder self.transport = transport } diff --git a/Mlem/Dependency/APIClient+Dependency.swift b/Mlem/Dependency/APIClient+Dependency.swift index d1c355c42..bd4e2b199 100644 --- a/Mlem/Dependency/APIClient+Dependency.swift +++ b/Mlem/Dependency/APIClient+Dependency.swift @@ -10,7 +10,7 @@ import Dependencies import Foundation extension APIClient: DependencyKey { - static let liveValue = APIClient { urlSession, urlRequest in try await urlSession.data(for: urlRequest) } + static let liveValue = APIClient(transport: { urlSession, urlRequest in try await urlSession.data(for: urlRequest) }) static let testValue = APIClient(transport: unimplemented()) } diff --git a/Mlem/Dependency/FavoriteCommunitiesTracker+Dependency.swift b/Mlem/Dependency/FavoriteCommunitiesTracker+Dependency.swift new file mode 100644 index 000000000..a79991e68 --- /dev/null +++ b/Mlem/Dependency/FavoriteCommunitiesTracker+Dependency.swift @@ -0,0 +1,21 @@ +// +// FavoriteCommunitiesTracker+Dependency.swift +// Mlem +// +// Created by mormaer on 11/08/2023. +// +// + +import Dependencies +import Foundation + +extension FavoriteCommunitiesTracker: DependencyKey { + static let liveValue = FavoriteCommunitiesTracker() + } + + extension DependencyValues { + var favoriteCommunitiesTracker: FavoriteCommunitiesTracker { + get { self[FavoriteCommunitiesTracker.self] } + set { self[FavoriteCommunitiesTracker.self] = newValue } + } +} diff --git a/Mlem/Dependency/Notifier+Dependency.swift b/Mlem/Dependency/Notifier+Dependency.swift index d584fec65..c4d47841b 100644 --- a/Mlem/Dependency/Notifier+Dependency.swift +++ b/Mlem/Dependency/Notifier+Dependency.swift @@ -9,7 +9,7 @@ import Dependencies extension Notifier: DependencyKey { - static let liveValue = Notifier() + static let liveValue = Notifier(display: { await NotificationDisplayer.display($0) }) } extension DependencyValues { diff --git a/Mlem/Extensions/String.swift b/Mlem/Extensions/String.swift index 0d19d1825..6e09cf15b 100644 --- a/Mlem/Extensions/String.swift +++ b/Mlem/Extensions/String.swift @@ -25,3 +25,9 @@ extension String { trimmingCharacters(in: .whitespacesAndNewlines) } } + +extension [String] { + static var alphabet: Self { + ["A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L", "M", "N", "O", "P", "Q", "R", "S", "T", "U", "V", "W", "X", "Y", "Z"] + } +} diff --git a/Mlem/Extensions/View - Handle Lemmy Links.swift b/Mlem/Extensions/View - Handle Lemmy Links.swift index 25c09e44d..c53e874b0 100644 --- a/Mlem/Extensions/View - Handle Lemmy Links.swift +++ b/Mlem/Extensions/View - Handle Lemmy Links.swift @@ -12,7 +12,6 @@ import SwiftUI struct HandleLemmyLinksDisplay: ViewModifier { @EnvironmentObject var appState: AppState @EnvironmentObject var filtersTracker: FiltersTracker - @EnvironmentObject var favoriteCommunitiesTracker: FavoriteCommunitiesTracker @EnvironmentObject var savedAccounts: SavedAccountTracker @AppStorage("internetSpeed") var internetSpeed: InternetSpeed = .fast @@ -26,21 +25,18 @@ struct HandleLemmyLinksDisplay: ViewModifier { .environmentObject(appState) .environmentObject(filtersTracker) .environmentObject(CommunitySearchResultsTracker()) - .environmentObject(favoriteCommunitiesTracker) } .navigationDestination(for: APICommunity.self) { community in FeedView(community: community, feedType: .all, sortType: defaultPostSorting) .environmentObject(appState) .environmentObject(filtersTracker) .environmentObject(CommunitySearchResultsTracker()) - .environmentObject(favoriteCommunitiesTracker) } .navigationDestination(for: CommunityLinkWithContext.self) { context in FeedView(community: context.community, feedType: context.feedType, sortType: defaultPostSorting) .environmentObject(appState) .environmentObject(filtersTracker) .environmentObject(CommunitySearchResultsTracker()) - .environmentObject(favoriteCommunitiesTracker) } .navigationDestination(for: CommunitySidebarLinkWithContext.self) { context in CommunitySidebarView( @@ -49,7 +45,6 @@ struct HandleLemmyLinksDisplay: ViewModifier { ) .environmentObject(filtersTracker) .environmentObject(CommunitySearchResultsTracker()) - .environmentObject(favoriteCommunitiesTracker) } .navigationDestination(for: APIPostView.self) { post in ExpandedPost(post: post) diff --git a/Mlem/Models/Trackers/Favorite Community Tracker.swift b/Mlem/Models/Trackers/Favorite Community Tracker.swift index 6a0ed3081..ecc6d555f 100644 --- a/Mlem/Models/Trackers/Favorite Community Tracker.swift +++ b/Mlem/Models/Trackers/Favorite Community Tracker.swift @@ -9,7 +9,6 @@ import Combine import Dependencies import Foundation -@MainActor class FavoriteCommunitiesTracker: ObservableObject { @Dependency(\.persistenceRepository) var persistenceRepository @@ -19,9 +18,7 @@ class FavoriteCommunitiesTracker: ObservableObject { init() { self.favoriteCommunities = persistenceRepository.loadFavoriteCommunities() self.updateObserver = $favoriteCommunities.sink { [weak self] value in - Task { - try await self?.persistenceRepository.saveFavoriteCommunities(value) - } + self?.save(value) } } @@ -40,4 +37,10 @@ class FavoriteCommunitiesTracker: ObservableObject { func unfavorite(_ community: APICommunity) { favoriteCommunities.removeAll(where: { $0.community.id == community.id }) } + + private func save(_ value: [FavoriteCommunity]) { + Task { [weak self] in + try await self?.persistenceRepository.saveFavoriteCommunities(value) + } + } } diff --git a/Mlem/Notifications/Notifier.swift b/Mlem/Notifications/Notifier.swift index 953b7c23e..1444f5e18 100644 --- a/Mlem/Notifications/Notifier.swift +++ b/Mlem/Notifications/Notifier.swift @@ -10,6 +10,8 @@ import Foundation /// An actor to queue notifications which should be presented to the user actor Notifier { + + private var display: (Notifiable) async -> Void private var queue = [Notifiable]() { didSet { guard !isNotifying else { return } @@ -19,6 +21,10 @@ actor Notifier { private var isNotifying = false + init(display: @escaping (Notifiable) async -> Void) { + self.display = display + } + func performWithLoader(_ operation: @Sendable @escaping () async -> Void) { queue.append( Task(priority: .userInitiated, operation: operation) @@ -51,7 +57,7 @@ actor Notifier { } queue.removeFirst() - await NotificationDisplayer.display(first) + await display(first) await notify() } } diff --git a/Mlem/Views/Tabs/Feeds/Community List/Community List View.swift b/Mlem/Views/Tabs/Feeds/Community List/Community List View.swift index 239e16cce..01ae7e2d8 100644 --- a/Mlem/Views/Tabs/Feeds/Community List/Community List View.swift +++ b/Mlem/Views/Tabs/Feeds/Community List/Community List View.swift @@ -17,30 +17,18 @@ struct CommunitySection: Identifiable { } struct CommunityListView: View { - @Dependency(\.communityRepository) var communityRepository - @Dependency(\.errorHandler) var errorHandler - @EnvironmentObject var favoritedCommunitiesTracker: FavoriteCommunitiesTracker - @EnvironmentObject var appState: AppState - @Environment(\.openURL) var openURL - @Environment(\.navigationPath) var navigationPath - @AppStorage("defaultFeed") var defaultFeed: FeedType = .subscribed - - @State var subscribedCommunities = [APICommunity]() - - // swiftlint:disable line_length - private static let alphabet = ["A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L", "M", "N", "O", "P", "Q", "R", "S", "T", "U", "V", "W", "X", "Y", "Z"] - // swiftlint:enable line_length - - // Note: These are in order that they appear in the sidebar - @State var communitySections: [CommunitySection] = [] - + @StateObject private var model: CommunityListModel + @Binding var selectedCommunity: CommunityLinkWithContext? - init(selectedCommunity: Binding) { + init(selectedCommunity: Binding, account: SavedAccount) { self._selectedCommunity = selectedCommunity + self._model = StateObject(wrappedValue: CommunityListModel(account: account)) } + // MARK: - Body + var body: some View { ScrollViewReader { scrollProxy in HStack { @@ -64,227 +52,51 @@ struct CommunityListView: View { iconColor: .blue, description: "All communities that federate with your server" ) - - ForEach(calculateVisibleCommunitySections()) { communitySection in - Section(header: - HStack { - Text(communitySection.inlineHeaderLabel!).accessibilityLabel(communitySection.accessibilityLabel) - Spacer() - }.id(communitySection.viewId)) { - ForEach( - calculateCommunityListSections(for: communitySection), - id: \.id - ) { listedCommunity in + + ForEach(model.visibleSections) { section in + Section(header: headerView(for: section)) { + ForEach(model.communities(for: section)) { community in CommuntiyFeedRowView( - community: listedCommunity, - subscribed: subscribedCommunities.contains(listedCommunity), - communitySubscriptionChanged: hydrateCommunityData + community: community, + subscribed: model.isSubscribed(to: community), + communitySubscriptionChanged: model.updateSubscriptionStatus ) } } + } } - } .fancyTabScrollCompatible() .navigationTitle("Communities") .navigationBarColor() .listStyle(PlainListStyle()) .scrollIndicators(.hidden) - SectionIndexTitles(proxy: scrollProxy, communitySections: communitySections) + SectionIndexTitles(proxy: scrollProxy, communitySections: model.allSections()) } } .refreshable { - await refreshCommunitiesList() + await model.load() } .onAppear { Task(priority: .high) { - await refreshCommunitiesList() - } - // Set up sections after we body is called - // so we can use the favorite tracker environment - communitySections = [ - CommunitySection( - viewId: "top", - sidebarEntry: EmptySidebarEntry( - sidebarLabel: nil, - sidebarIcon: "line.3.horizontal" - ), - inlineHeaderLabel: nil, - accessibilityLabel: "Top of communities" - ), - CommunitySection( - viewId: "favorites", - sidebarEntry: FavoritesSidebarEntry( - account: appState.currentActiveAccount, - favoritesTracker: favoritedCommunitiesTracker, - sidebarLabel: nil, - sidebarIcon: "star.fill" - ), - inlineHeaderLabel: "Favorites", - accessibilityLabel: "Favorited Communities" - ) - ] + - CommunityListView.alphabet.map { - // This looks sinister but I didn't know how to string replace in a non-string based regex - CommunitySection( - viewId: $0, - sidebarEntry: RegexCommunityNameSidebarEntry( - communityNameRegex: (try? Regex("^[\($0.uppercased())\($0.lowercased())]"))!, - sidebarLabel: $0, - sidebarIcon: nil - ), - inlineHeaderLabel: $0, - accessibilityLabel: "Communities starting with the letter '\($0)'" - ) - } + - [CommunitySection( - viewId: "non_letter_titles", - sidebarEntry: RegexCommunityNameSidebarEntry( - communityNameRegex: /^[^a-zA-Z]/, - sidebarLabel: "#", - sidebarIcon: nil - ), - inlineHeaderLabel: "#", - accessibilityLabel: "Communities starting with a symbol or number" - )] - } - } - - private func refreshCommunitiesList() async { - do { - subscribedCommunities = try await communityRepository - .loadSubscriptions() - .map(\.community) - .sorted() - } catch { - errorHandler.handle(error) - } - } - - private func calculateCommunityListSections(for section: CommunitySection) -> [APICommunity] { - // Filter down to sidebar entry which wants us - getSubscriptionsAndFavorites() - .filter { listedCommunity -> Bool in - section.sidebarEntry.contains(community: listedCommunity, isSubscribed: subscribedCommunities.contains(listedCommunity)) - } - } - - private func calculateVisibleCommunitySections() -> [CommunitySection] { - communitySections - - // Only show letter headers for letters we have in our community list - .filter { communitySection -> Bool in - getSubscriptionsAndFavorites() - .contains(where: { communitySection.sidebarEntry - .contains(community: $0, isSubscribed: subscribedCommunities.contains($0)) - }) - } - // Only show sections which have labels to show - .filter { communitySection -> Bool in - communitySection.inlineHeaderLabel != nil - } - } - - private func hydrateCommunityData(community: APICommunity, isSubscribed: Bool) { - // Add or remove subscribed sub locally - if isSubscribed { - subscribedCommunities.append(community) - subscribedCommunities = subscribedCommunities.sorted() - } else { - if let index = subscribedCommunities.firstIndex(where: { $0 == community }) { - subscribedCommunities.remove(at: index) + await model.load() } } } - - func getSubscriptionsAndFavorites() -> [APICommunity] { - var result = subscribedCommunities - - // Merge in our favorites list too just incase we aren't subscribed to our favorites - result.append(contentsOf: favoritedCommunitiesTracker.favoriteCommunities.map(\.community)) - - // Remove duplicates and sort by name - result = Array(Set(result)).sorted() - - return result - } -} - -// Original article here: https://www.fivestars.blog/code/section-title-index-swiftui.html -struct SectionIndexTitles: View { - @Dependency(\.hapticManager) var hapticManager - let proxy: ScrollViewProxy - let communitySections: [CommunitySection] - @GestureState private var dragLocation: CGPoint = .zero - - // Track which sidebar label we picked last to we - // only haptic when selecting a new one - @State var lastSelectedLabel: String = "" - - var body: some View { - VStack { - ForEach(communitySections) { communitySection in - HStack { - if communitySection.sidebarEntry.sidebarIcon != nil { - SectionIndexImage(image: communitySection.sidebarEntry.sidebarIcon!) - .padding(.trailing) - } else if communitySection.sidebarEntry.sidebarLabel != nil { - SectionIndexText(label: communitySection.sidebarEntry.sidebarLabel!) - .padding(.trailing) - } else { - EmptyView() - } - } - .background(dragObserver(viewId: communitySection.viewId)) - } - } - .gesture( - DragGesture(minimumDistance: 0, coordinateSpace: .global) - .updating($dragLocation) { value, state, _ in - state = value.location - } - ) - } - - func dragObserver(viewId: String) -> some View { - GeometryReader { geometry in - dragObserver(geometry: geometry, viewId: viewId) - } - } - - func dragObserver(geometry: GeometryProxy, viewId: String) -> some View { - if geometry.frame(in: .global).contains(dragLocation) { - if viewId != lastSelectedLabel { - DispatchQueue.main.async { - lastSelectedLabel = viewId - proxy.scrollTo(viewId, anchor: .center) - - // Play nice tappy taps - // HapticManager.shared.rigidInfo() - hapticManager.play(haptic: .rigidInfo, priority: .low) - } - } + // MARK: - Subviews + + private func headerView(for section: CommunitySection) -> some View { + HStack { + Text(section.inlineHeaderLabel!) + .accessibilityLabel(section.accessibilityLabel) + Spacer() } - return Rectangle().fill(Color.clear) + .id(section.viewId) } } -// Sidebar Label Views -struct SectionIndexText: View { - let label: String - var body: some View { - Text(label).font(.system(size: 12)).bold() - } -} - -struct SectionIndexImage: View { - let image: String - var body: some View { - Image(systemName: image).resizable() - .frame(width: 8, height: 8) - } -} +// MARK: - Previews struct CommunityListViewPreview: PreviewProvider { static var appState = AppState( @@ -296,7 +108,8 @@ struct CommunityListViewPreview: PreviewProvider { Group { NavigationStack { CommunityListView( - selectedCommunity: .constant(nil) + selectedCommunity: .constant(nil), + account: .mock() ) .environmentObject( FavoriteCommunitiesTracker() @@ -311,7 +124,8 @@ struct CommunityListViewPreview: PreviewProvider { $0.communityRepository.subscriptions = { _ in [] } } operation: { CommunityListView( - selectedCommunity: .constant(nil) + selectedCommunity: .constant(nil), + account: .mock() ) .environmentObject( FavoriteCommunitiesTracker() @@ -329,7 +143,8 @@ struct CommunityListViewPreview: PreviewProvider { } } operation: { CommunityListView( - selectedCommunity: .constant(nil) + selectedCommunity: .constant(nil), + account: .mock() ) .environmentObject( FavoriteCommunitiesTracker() diff --git a/Mlem/Views/Tabs/Feeds/Community List/CommunityListModel.swift b/Mlem/Views/Tabs/Feeds/Community List/CommunityListModel.swift new file mode 100644 index 000000000..fd3618797 --- /dev/null +++ b/Mlem/Views/Tabs/Feeds/Community List/CommunityListModel.swift @@ -0,0 +1,239 @@ +// +// CommunityListModel.swift +// Mlem +// +// Created by mormaer on 11/08/2023. +// +// + +import Combine +import Dependencies +import Foundation + +class CommunityListModel: ObservableObject { + + @Dependency(\.communityRepository) var communityRepository + @Dependency(\.errorHandler) var errorHandler + @Dependency(\.favoriteCommunitiesTracker) var favoriteCommunitiesTracker + @Dependency(\.notifier) var notifier + @Dependency(\.mainQueue) var mainQueue + + @Published private(set) var communities = [APICommunity]() + + private let account: SavedAccount + private var subscriptions = [APICommunity]() + private var favoriteCommunities = [APICommunity]() + private var cancellables = Set() + + init(account: SavedAccount) { + self.account = account + favoriteCommunitiesTracker + .$favoriteCommunities + .dropFirst() + .sink { [weak self] value in + self?.updateFavorites(value) + } + .store(in: &cancellables) + } + + // MARK: - Public methods + + func load() async { + do { + // load our subscribed communities + let subscriptions = try await communityRepository + .loadSubscriptions() + .map { $0.community } + + // load our favourite communities + let favorites = favoriteCommunitiesTracker.favoriteCommunities(for: account) + + // combine the two lists + combine(subscriptions, favorites) + } catch { + errorHandler.handle( + .init(underlyingError: error) + ) + } + } + + func isSubscribed(to community: APICommunity) -> Bool { + subscriptions.contains(community) + } + + func updateSubscriptionStatus(for community: APICommunity, subscribed: Bool) { + // immediately update our local state + updateLocalStatus(for: community, subscribed: subscribed) + + // then attempt to update our remote state + Task { + await updateRemoteStatus(for: community, subscribed: subscribed) + } + } + + var visibleSections: [CommunitySection] { + allSections() + + // Only show sections which have labels to show + .filter { communitySection -> Bool in + communitySection.inlineHeaderLabel != nil + } + + // Only show letter headers for letters we have in our community list + .filter { communitySection -> Bool in + communities + .contains(where: { communitySection.sidebarEntry + .contains(community: $0, isSubscribed: isSubscribed(to: $0)) }) + } + } + + func communities(for section: CommunitySection) -> [APICommunity] { + // Filter down to sidebar entry which wants us + return communities + .filter { community -> Bool in + section.sidebarEntry.contains(community: community, isSubscribed: isSubscribed(to: community)) + } + } + + func allSections() -> [CommunitySection] { + var sections = [CommunitySection]() + + sections.append( + withDependencies(from: self) { + CommunitySection( + viewId: "top", + sidebarEntry: EmptySidebarEntry( + sidebarLabel: nil, + sidebarIcon: "line.3.horizontal" + ), + inlineHeaderLabel: nil, + accessibilityLabel: "Top of communities" + ) + } + ) + + sections.append( + withDependencies(from: self) { + CommunitySection( + viewId: "favorites", + sidebarEntry: FavoritesSidebarEntry( + account: account, + sidebarLabel: nil, + sidebarIcon: "star.fill" + ), + inlineHeaderLabel: "Favorites", + accessibilityLabel: "Favorited Communities" + ) + } + ) + + sections.append(contentsOf: alphabeticSections()) + + sections.append( + withDependencies(from: self) { + CommunitySection( + viewId: "non_letter_titles", + sidebarEntry: RegexCommunityNameSidebarEntry( + communityNameRegex: /^[^a-zA-Z]/, + sidebarLabel: "#", + sidebarIcon: nil + ), + inlineHeaderLabel: "#", + accessibilityLabel: "Communities starting with a symbol or number" + ) + } + ) + + return sections + } + + func alphabeticSections() -> [CommunitySection] { + let alphabet: [String] = .alphabet + return alphabet.map { character in + withDependencies(from: self) { + // This looks sinister but I didn't know how to string replace in a non-string based regex + CommunitySection( + viewId: character, + sidebarEntry: RegexCommunityNameSidebarEntry( + communityNameRegex: (try? Regex("^[\(character.uppercased())\(character.lowercased())]"))!, + sidebarLabel: character, + sidebarIcon: nil + ), + inlineHeaderLabel: character, + accessibilityLabel: "Communities starting with the letter '\(character)'" + ) + } + } + } + + // MARK: - Private methods + + private func updateLocalStatus(for community: APICommunity, subscribed: Bool) { + var updatedSubscriptions = subscriptions + + if subscribed { + updatedSubscriptions.append(community) + } else { + if let index = updatedSubscriptions.firstIndex(where: { $0 == community }) { + updatedSubscriptions.remove(at: index) + } + } + + combine(updatedSubscriptions, favoriteCommunities) + } + + private func updateRemoteStatus(for community: APICommunity, subscribed: Bool) async { + do { + let updatedCommunity = try await communityRepository.updateSubscription(for: community.id, subscribed: subscribed).community + + if subscribed { + await notifier.add(.success("Subscibed to \(community.name)")) + } else { + await notifier.add(.success("Unsubscribed from \(community.name)")) + } + + if let indexToUpdate = subscriptions.firstIndex(where: { $0.id == updatedCommunity.id }) { + var updatedSubscriptions = subscriptions + updatedSubscriptions[indexToUpdate] = updatedCommunity + combine(updatedSubscriptions, favoriteCommunities) + } + } catch { + let phrase = subscribed ? "subscribe to" : "unsubscribe from" + errorHandler.handle( + .init( + title: "Unable to \(phrase) community", + style: .toast, + underlyingError: error + ) + ) + + // as the call failed, we need to revert the change to the local state + await MainActor.run { + updateLocalStatus(for: community, subscribed: !subscribed) + } + } + } + + private func updateFavorites(_ favorites: [FavoriteCommunity]) { + let filteredFavorites = favorites + .filter { $0.forAccountID == account.id } + .map { $0.community } + + combine(subscriptions, filteredFavorites) + } + + private func combine(_ subscriptions: [APICommunity], _ favorites: [APICommunity]) { + // store the values for future use... + self.subscriptions = subscriptions + self.favoriteCommunities = favorites + + // combine and sort the two lists, excluding duplicates + let combined = subscriptions + favorites.filter { !subscriptions.contains($0) } + let sorted = combined.sorted() + + // update our published value for the view to render + mainQueue.schedule { [weak self] in + self?.communities = sorted + } + } +} diff --git a/Mlem/Views/Tabs/Feeds/Community List/Components/CommunityListRowViews.swift b/Mlem/Views/Tabs/Feeds/Community List/Components/CommunityListRowViews.swift index cf12ab587..8d8fa3381 100644 --- a/Mlem/Views/Tabs/Feeds/Community List/Components/CommunityListRowViews.swift +++ b/Mlem/Views/Tabs/Feeds/Community List/Components/CommunityListRowViews.swift @@ -8,17 +8,6 @@ import Dependencies import SwiftUI -struct HeaderView: View { - let title: String - - var body: some View { - HStack { - Text(title) - Spacer() - } - } -} - struct FavoriteStarButtonStyle: ButtonStyle { let isFavorited: Bool @@ -31,8 +20,8 @@ struct FavoriteStarButtonStyle: ButtonStyle { } struct CommuntiyFeedRowView: View { - @Dependency(\.communityRepository) var communityRepository - @Dependency(\.errorHandler) var errorHandler + + @Dependency(\.favoriteCommunitiesTracker) var favoriteCommunitiesTracker @Dependency(\.hapticManager) var hapticManager @Dependency(\.notifier) var notifier @@ -41,7 +30,6 @@ struct CommuntiyFeedRowView: View { let communitySubscriptionChanged: (APICommunity, Bool) -> Void @EnvironmentObject var appState: AppState - @EnvironmentObject var favoritesTracker: FavoriteCommunitiesTracker var body: some View { NavigationLink(value: CommunityLinkWithContext(community: community, feedType: .subscribed)) { @@ -112,13 +100,13 @@ struct CommuntiyFeedRowView: View { private func toggleFavorite() { if isFavorited() { - favoritesTracker.unfavorite(community) + favoriteCommunitiesTracker.unfavorite(community) UIAccessibility.post(notification: .announcement, argument: "Unfavorited \(community.name)") Task { await notifier.add(.success("Unfavorited \(community.name)")) } } else { - favoritesTracker.favorite(community, for: appState.currentActiveAccount) + favoriteCommunitiesTracker.favorite(community, for: appState.currentActiveAccount) UIAccessibility.post(notification: .announcement, argument: "Favorited \(community.name)") Task { await notifier.add(.success("Favorited \(community.name)")) @@ -127,32 +115,11 @@ struct CommuntiyFeedRowView: View { } private func isFavorited() -> Bool { - favoritesTracker.favoriteCommunities(for: appState.currentActiveAccount).contains(community) + favoriteCommunitiesTracker.favoriteCommunities(for: appState.currentActiveAccount).contains(community) } private func subscribe(communityId: Int, shouldSubscribe: Bool) async { - // Refresh the list locally immedietly and undo it if we error communitySubscriptionChanged(community, shouldSubscribe) - - do { - try await communityRepository.updateSubscription(for: communityId, subscribed: shouldSubscribe) - - if shouldSubscribe { - await notifier.add(.success("Subscibed to \(community.name)")) - } else { - await notifier.add(.success("Unsubscribed from \(community.name)")) - } - } catch { - let phrase = shouldSubscribe ? "subscribe to" : "unsubscribe from" - errorHandler.handle( - .init( - title: "Unable to \(phrase) community", - style: .toast, - underlyingError: error - ) - ) - communitySubscriptionChanged(community, !shouldSubscribe) - } } } diff --git a/Mlem/Views/Tabs/Feeds/Community List/Components/CommunityListSidebarEntry.swift b/Mlem/Views/Tabs/Feeds/Community List/Components/CommunityListSidebarEntry.swift index 3c4f6ea20..b8fe55116 100644 --- a/Mlem/Views/Tabs/Feeds/Community List/Components/CommunityListSidebarEntry.swift +++ b/Mlem/Views/Tabs/Feeds/Community List/Components/CommunityListSidebarEntry.swift @@ -5,11 +5,13 @@ // Created by Jake Shirley on 6/19/23. // +import Dependencies import Foundation protocol SidebarEntry { - var sidebarLabel: String? { get set } - var sidebarIcon: String? { get set } + var sidebarLabel: String? { get } + var sidebarIcon: String? { get } + func contains(community: APICommunity, isSubscribed: Bool) -> Bool } @@ -40,13 +42,15 @@ struct RegexCommunityNameSidebarEntry: SidebarEntry { // Filters to favorited communities struct FavoritesSidebarEntry: SidebarEntry { + + @Dependency(\.favoriteCommunitiesTracker) var favoriteCommunitiesTracker + let account: SavedAccount - let favoritesTracker: FavoriteCommunitiesTracker var sidebarLabel: String? var sidebarIcon: String? @MainActor func contains(community: APICommunity, isSubscribed: Bool) -> Bool { - favoritesTracker.favoriteCommunities(for: account).contains(community) + favoriteCommunitiesTracker.favoriteCommunities(for: account).contains(community) } } diff --git a/Mlem/Views/Tabs/Feeds/Community List/Components/SectionIndexTitles.swift b/Mlem/Views/Tabs/Feeds/Community List/Components/SectionIndexTitles.swift new file mode 100644 index 000000000..835e4c01c --- /dev/null +++ b/Mlem/Views/Tabs/Feeds/Community List/Components/SectionIndexTitles.swift @@ -0,0 +1,87 @@ +// +// SectionIndexTitles.swift +// Mlem +// +// Created by mormaer on 13/08/2023. +// +// + +import Dependencies +import SwiftUI + +// Original article here: https://www.fivestars.blog/code/section-title-index-swiftui.html +struct SectionIndexTitles: View { + + @Dependency(\.hapticManager) var hapticManager + + let proxy: ScrollViewProxy + let communitySections: [CommunitySection] + @GestureState private var dragLocation: CGPoint = .zero + + // Track which sidebar label we picked last to we + // only haptic when selecting a new one + @State var lastSelectedLabel: String = "" + + var body: some View { + VStack { + ForEach(communitySections) { communitySection in + HStack { + if communitySection.sidebarEntry.sidebarIcon != nil { + SectionIndexImage(image: communitySection.sidebarEntry.sidebarIcon!) + .padding(.trailing) + } else if communitySection.sidebarEntry.sidebarLabel != nil { + SectionIndexText(label: communitySection.sidebarEntry.sidebarLabel!) + .padding(.trailing) + } else { + EmptyView() + } + } + .background(dragObserver(viewId: communitySection.viewId)) + } + } + .gesture( + DragGesture(minimumDistance: 0, coordinateSpace: .global) + .updating($dragLocation) { value, state, _ in + state = value.location + } + ) + } + + func dragObserver(viewId: String) -> some View { + GeometryReader { geometry in + dragObserver(geometry: geometry, viewId: viewId) + } + } + + func dragObserver(geometry: GeometryProxy, viewId: String) -> some View { + if geometry.frame(in: .global).contains(dragLocation) { + if viewId != lastSelectedLabel { + DispatchQueue.main.async { + lastSelectedLabel = viewId + proxy.scrollTo(viewId, anchor: .center) + + // Play nice tappy taps + // HapticManager.shared.rigidInfo() + hapticManager.play(haptic: .rigidInfo, priority: .low) + } + } + } + return Rectangle().fill(Color.clear) + } +} + +// Sidebar Label Views +struct SectionIndexText: View { + let label: String + var body: some View { + Text(label).font(.system(size: 12)).bold() + } +} + +struct SectionIndexImage: View { + let image: String + var body: some View { + Image(systemName: image).resizable() + .frame(width: 8, height: 8) + } +} diff --git a/Mlem/Views/Tabs/Feeds/Feed Root.swift b/Mlem/Views/Tabs/Feeds/Feed Root.swift index c090d1dde..5d3da0a56 100644 --- a/Mlem/Views/Tabs/Feeds/Feed Root.swift +++ b/Mlem/Views/Tabs/Feeds/Feed Root.swift @@ -25,7 +25,7 @@ struct FeedRoot: View { var body: some View { NavigationSplitView { - CommunityListView(selectedCommunity: $rootDetails) + CommunityListView(selectedCommunity: $rootDetails, account: appState.currentActiveAccount) .id(appState.currentActiveAccount.id) } detail: { if let rootDetails { diff --git a/Mlem/Views/Tabs/Feeds/Feed View.swift b/Mlem/Views/Tabs/Feeds/Feed View.swift index e8c4f19a0..0a48fb7c5 100644 --- a/Mlem/Views/Tabs/Feeds/Feed View.swift +++ b/Mlem/Views/Tabs/Feeds/Feed View.swift @@ -14,6 +14,7 @@ struct FeedView: View { @Dependency(\.communityRepository) var communityRepository @Dependency(\.errorHandler) var errorHandler + @Dependency(\.favoriteCommunitiesTracker) var favoriteCommunitiesTracker @Dependency(\.hapticManager) var hapticManager @Dependency(\.notifier) var notifier @@ -25,7 +26,6 @@ struct FeedView: View { @EnvironmentObject var appState: AppState @EnvironmentObject var filtersTracker: FiltersTracker - @EnvironmentObject var favoriteCommunitiesTracker: FavoriteCommunitiesTracker @EnvironmentObject var editorTracker: EditorTracker // MARK: Parameters and init diff --git a/Mlem/Views/Tabs/Settings/Components/Views/General/GeneralSettingsView.swift b/Mlem/Views/Tabs/Settings/Components/Views/General/GeneralSettingsView.swift index 3b648eb90..e43b9540e 100644 --- a/Mlem/Views/Tabs/Settings/Components/Views/General/GeneralSettingsView.swift +++ b/Mlem/Views/Tabs/Settings/Components/Views/General/GeneralSettingsView.swift @@ -5,9 +5,13 @@ // Created by David Bureš on 19.05.2023. // +import Dependencies import SwiftUI struct GeneralSettingsView: View { + + @Dependency(\.favoriteCommunitiesTracker) var favoriteCommunitiesTracker + @AppStorage("shouldBlurNsfw") var shouldBlurNsfw: Bool = true @AppStorage("internetSpeed") var internetSpeed: InternetSpeed = .fast @@ -18,7 +22,6 @@ struct GeneralSettingsView: View { @AppStorage("hapticLevel") var hapticLevel: HapticPriority = .low - @EnvironmentObject var favoritesTracker: FavoriteCommunitiesTracker @EnvironmentObject var appState: AppState @State private var isShowingFavoritesDeletionConfirmation: Bool = false @@ -96,20 +99,20 @@ struct GeneralSettingsView: View { } label: { Label("Delete Community Favorites", systemImage: "trash") .foregroundColor(.red) - .opacity(favoritesTracker.favoriteCommunities.isEmpty ? 0.6 : 1) + .opacity(favoriteCommunitiesTracker.favoriteCommunities.isEmpty ? 0.6 : 1) } - .disabled(favoritesTracker.favoriteCommunities.isEmpty) + .disabled(favoriteCommunitiesTracker.favoriteCommunities.isEmpty) .confirmationDialog( "Delete community favorites for all accounts?", isPresented: $isShowingFavoritesDeletionConfirmation, titleVisibility: .visible ) { Button(role: .destructive) { - favoritesTracker.favoriteCommunities = .init() + favoriteCommunitiesTracker.favoriteCommunities = .init() } label: { Text("Delete all favorites") } - + Button(role: .cancel) { isShowingFavoritesDeletionConfirmation.toggle() } label: { diff --git a/Mlem/Window.swift b/Mlem/Window.swift index 528819ccc..475a16d6e 100644 --- a/Mlem/Window.swift +++ b/Mlem/Window.swift @@ -13,8 +13,7 @@ struct Window: View { @Dependency(\.notifier) var notifier @Dependency(\.hapticManager) var hapticManager @Dependency(\.siteInformation) var siteInformation - - @StateObject var favoriteCommunitiesTracker: FavoriteCommunitiesTracker = .init() + @StateObject var communitySearchResultsTracker: CommunitySearchResultsTracker = .init() @StateObject var easterFlagsTracker: EasterFlagsTracker = .init() @StateObject var filtersTracker: FiltersTracker = .init() @@ -61,7 +60,6 @@ struct Window: View { .id(account.id) .environmentObject(filtersTracker) .environmentObject(AppState(defaultAccount: account, selectedAccount: $selectedAccount)) - .environmentObject(favoriteCommunitiesTracker) .environmentObject(communitySearchResultsTracker) .environmentObject(recentSearchesTracker) .environmentObject(easterFlagsTracker) diff --git a/MlemTests/Community List/CommunityListModelTests.swift b/MlemTests/Community List/CommunityListModelTests.swift new file mode 100644 index 000000000..f67e0e15d --- /dev/null +++ b/MlemTests/Community List/CommunityListModelTests.swift @@ -0,0 +1,334 @@ +// +// CommunityListModelTests.swift +// MlemTests +// +// Created by mormaer on 13/08/2023. +// +// + +@testable import Mlem + +import Dependencies +import XCTest + +final class CommunityListModelTests: XCTestCase { + private let account: SavedAccount = .mock() + + override func setUpWithError() throws { + favoritesData = Data() + } + + override func tearDownWithError() throws {} + + func testInitialState() async throws { + // set some favorites data + favoritesData = try JSONEncoder().encode([ + FavoriteCommunity(forAccountID: account.id, community: .mock(id: 42)) + ]) + + let model = withDependencies { + $0.favoriteCommunitiesTracker = favoritesTracker + $0.communityRepository.subscriptions = { _ in + // return a subscription for the user + [.mock(community: .mock(id: 0), subscribed: .subscribed)] + } + } operation: { + CommunityListModel(account: account) + } + + // assert that even though a subscription and favorite are available nothing is present without `load()` being called + XCTAssert(model.communities.isEmpty) + } + + func testLoadingWithNoSubscriptionsOrFavourites() async throws { + let model = withDependencies { + $0.mainQueue = .immediate + $0.favoriteCommunitiesTracker = favoritesTracker + // return an empty array from the the community repository to indicate the user has no subscriptions + $0.communityRepository.subscriptions = { _ in [] } + } operation: { + CommunityListModel(account: account) + } + + // ask the model to load + await model.load() + // assert after loading it's empty as there are no subscriptions or favourites for this user + XCTAssert(model.communities.isEmpty) + } + + func testLoadingWithSubscriptionAndFavorite() async throws { + // set some favorites data + favoritesData = try JSONEncoder().encode([ + FavoriteCommunity(forAccountID: account.id, community: .mock(id: 42, name: "favorite community")) + ]) + + let subscription = APICommunityView.mock(community: .mock(id: 0, name: "subscribed community"), subscribed: .subscribed) + + let model = withDependencies { + $0.mainQueue = .immediate + $0.favoriteCommunitiesTracker = favoritesTracker + $0.communityRepository.subscriptions = { _ in + // provide a subscription for the user + [subscription] + } + } operation: { + CommunityListModel(account: account) + } + + // ask the modek to load + await model.load() + + // assert both the favorite and subscription are present in the model + XCTAssert(model.communities.count == 2) + XCTAssert(model.communities[0].name == "favorite community") + XCTAssert(model.communities[1].name == "subscribed community") + } + + func testDuplicatesAreHandledCorrectly() async throws { + let community: APICommunity = .mock(id: 42) + + // set the above community as our only favorite + favoritesData = try JSONEncoder().encode([ + FavoriteCommunity(forAccountID: account.id, community: community) + ]) + + // use the same community as our only subscriptiom + let subscription = APICommunityView.mock(community: community, subscribed: .subscribed) + + let model = withDependencies { + $0.mainQueue = .immediate + $0.favoriteCommunitiesTracker = favoritesTracker + $0.communityRepository.subscriptions = { _ in + // provide a subscription for the user + [subscription] + } + } operation: { + CommunityListModel(account: account) + } + + // ask the model to load + await model.load() + // expectation is that although we will load the same community in favorites and subscriptions + // when the two lists combine the duplicate will be excluded, leaving only one copy of it + XCTAssert(model.communities.count == 1) + XCTAssert(model.communities[0].id == 42) + } + + func testSubscribedStatusIsCorrect() async throws { + let communityView: APICommunityView = .mock( + community: .mock(id: 42), + subscribed: .subscribed + ) + + let model = withDependencies { + $0.mainQueue = .immediate + $0.favoriteCommunitiesTracker = favoritesTracker + // return a single community under subscriptions for this test + $0.communityRepository.subscriptions = { _ in [communityView] } + } operation: { + CommunityListModel(account: account) + } + + // ask the model to load + await model.load() + // assert only one subscription is present + XCTAssert(model.communities.count == 1) + // assert the model correctly identfies if we're subscribed + XCTAssert(model.isSubscribed(to: communityView.community)) + // assert the model correctly identifies when we're not subscribed by passing a different community + XCTAssertFalse(model.isSubscribed(to: .mock(id: 24))) + } + + func testSuccessfulSubscriptionUpdate() async throws { + let model = withDependencies { + $0.mainQueue = .immediate + $0.notifier = Notifier(display: { _ in /* ignore notifications in this test */ }) + $0.favoriteCommunitiesTracker = favoritesTracker + $0.communityRepository.subscriptions = { _ in + // return no subscriptions + [] + } + // when asked to update the remote subscription return successfully + $0.communityRepository.updateSubscription = { _, communityId, subscribed in + APICommunityView.mock(community: .mock(id: communityId), subscribed: subscribed ? .subscribed : .notSubscribed) + } + } operation: { + CommunityListModel(account: account) + } + + // load the model + await model.load() + // assert we have a blank slate + XCTAssert(model.communities.isEmpty) + // tell the model to subscribe to a community + model.updateSubscriptionStatus(for: .mock(id: 42), subscribed: true) + // assert it is _immediately_ added to the communities (state faking) + XCTAssert(model.communities.count == 1) + XCTAssert(model.communities[0].id == 42) + // allow suspension so the model can make the remote call (stubbed as `.updateSubscription` above) + await Task.megaYield(count: 1000) + // assert the community remains in our list as the _remote_ call succeeded + XCTAssert(model.communities.count == 1) + XCTAssert(model.communities[0].id == 42) + } + + func testFailedSubscriptionUpdate() async throws { + let model = withDependencies { + $0.mainQueue = .immediate + $0.errorHandler = MockErrorHandler(didReceiveError: { _ in /* ignore the returned error */ }) + $0.favoriteCommunitiesTracker = favoritesTracker + $0.communityRepository.subscriptions = { _ in + // return no subscriptions + [] + } + // when asked to update the remote subscription throw an error + $0.communityRepository.updateSubscription = { _, _, _ in + throw APIClientError.cancelled + } + } operation: { + CommunityListModel(account: account) + } + + // load the model + await model.load() + // assert we have a blank slate + XCTAssert(model.communities.isEmpty) + // tell the model to subscribe to a community + model.updateSubscriptionStatus(for: .mock(id: 42), subscribed: true) + // assert it is _immediately_ added to the communities (state faking) + XCTAssert(model.communities.count == 1) + XCTAssert(model.communities[0].id == 42) + // allow suspension so the model can make the remote call (stubbed as `.updateSubscription` above) + await Task.megaYield(count: 1000) + // assert the community has been removed from our list as the _remote_ call failed in this test + XCTAssert(model.communities.isEmpty) + } + + func testModelRespondsToFavorites() async throws { + // hold on to our tracker in this test so we can exercise it's methods + let tracker = favoritesTracker + + let model = withDependencies { + $0.mainQueue = .immediate + $0.favoriteCommunitiesTracker = tracker + // return an empty array from the the community repository to indicate the user has no subscriptions + $0.communityRepository.subscriptions = { _ in [] } + } operation: { + CommunityListModel(account: account) + } + + // ask the model to load + await model.load() + // assert the model is not displaying any favorites + XCTAssertFalse(model.visibleSections.contains(where: { $0.viewId == "favorites" })) + // add a favorite to the tracker, expectation is the model will observe this change and update itself + let favoriteCommunity = APICommunity.mock(id: 42) + tracker.favorite(favoriteCommunity, for: account) + // assert that adding this favorite resulted in the model updating, it should now display a favorites section + XCTAssert(model.visibleSections.contains(where: { $0.viewId == "favorites" })) + XCTAssert(model.communities.first! == favoriteCommunity) + // now unfavorite the community + tracker.unfavorite(favoriteCommunity) + // assert that the favorites section is no longer included + XCTAssertFalse(model.visibleSections.contains(where: { $0.viewId == "favorites" })) + } + + func testCorrectCommunitiesAreReturnedForSections() async throws { + let communities: [APICommunityView] = [ + .mock(community: .mock(id: 0, name: "accordion")), + .mock(community: .mock(id: 1, name: "harp")), + .mock(community: .mock(id: 2, name: "harmonica")), + .mock(community: .mock(id: 3, name: "trombone")), + .mock(community: .mock(id: 4, name: "xylophone")), + .mock(community: .mock(id: 5, name: "glockenspiel")), + .mock(community: .mock(id: 6, name: "tuba")) + ] + + let model = withDependencies { + $0.mainQueue = .immediate + $0.favoriteCommunitiesTracker = favoritesTracker + $0.communityRepository.subscriptions = { _ in + // return our example communities from above ^ + communities + } + } operation: { + CommunityListModel(account: account) + } + + // ask the model to load + await model.load() + // assert all the communities are present + XCTAssert(model.communities.count == communities.count) + // assert we have the correct number of visible sections, some will group together... + XCTAssert(model.visibleSections.count == 5) + // assuming alphabetical ordering, assert we get the correct communities back for each section + XCTAssertEqual( + // section 0 (aka 'A') should include 'accordion' + model.communities(for: model.visibleSections[0]), + [communities[0].community] + ) + XCTAssertEqual( + // section 1 (aka 'G') should include 'glockenspiel' + model.communities(for: model.visibleSections[1]), + [communities[5].community] + ) + XCTAssertEqual( + // section 2 (aka 'H') should include 'harmonica' and 'harp' + model.communities(for: model.visibleSections[2]), + [communities[2].community, communities[1].community] + ) + XCTAssertEqual( + // section 3 (aka 'T') should include 'trombone' and 'tuba' + model.communities(for: model.visibleSections[3]), + [communities[3].community, communities[6].community] + ) + XCTAssertEqual( + // section 4 (aka 'X') should include 'xylophone' + model.communities(for: model.visibleSections[4]), + [communities[4].community] + ) + } + + func testAllSectionsOrder() async throws { + let model = withDependencies { + $0.favoriteCommunitiesTracker = favoritesTracker + } operation: { + CommunityListModel(account: account) + } + + // expectation is the all sections are made up of: + // - top section + // - favorites + // - alphabetics (a-z) + // - non-letter (symbols/numerics) + + // assert we have 26 for alphabet + 3 + XCTAssert(model.allSections().count == 29) + // assert order + XCTAssert(model.allSections()[0].viewId == "top") + XCTAssert(model.allSections()[1].viewId == "favorites") + + let alphabet: [String] = .alphabet + let offset = 2 + alphabet.enumerated().forEach { index, character in + XCTAssert(model.allSections()[index + offset].viewId == character) + } + XCTAssert(model.allSections()[28].viewId == "non_letter_titles") + } + + // MARK: - Helpers + + var favoritesData = Data() + + var favoritesTracker: FavoriteCommunitiesTracker { + withDependencies { + $0.persistenceRepository = .init( + keychainAccess: unimplemented(), + read: { _ in self.favoritesData }, + write: { data, _ in self.favoritesData = data } + ) + } operation: { + FavoriteCommunitiesTracker() + } + } +}