diff --git a/DB/Sources/DB/Content/ContentDatabase+Migration.swift b/DB/Sources/DB/Content/ContentDatabase+Migration.swift index 7d0a0d1..aa2547c 100644 --- a/DB/Sources/DB/Content/ContentDatabase+Migration.swift +++ b/DB/Sources/DB/Content/ContentDatabase+Migration.swift @@ -202,6 +202,24 @@ extension ContentDatabase { t.column("statusId").references("statusRecord", onDelete: .cascade) } + try db.create(table: "instanceRecord") { t in + t.column("uri", .text).primaryKey(onConflict: .replace) + t.column("title", .text).notNull() + t.column("description", .text).notNull() + t.column("shortDescription", .text) + t.column("email", .text).notNull() + t.column("version", .text).notNull() + t.column("languages", .blob).notNull() + t.column("registrations", .boolean).notNull() + t.column("approvalRequired", .boolean).notNull() + t.column("invitesEnabled", .boolean).notNull() + t.column("urls", .blob).notNull() + t.column("stats", .blob).notNull() + t.column("thumbnail", .text) + t.column("contactAccountId", .text).references("accountRecord", onDelete: .cascade) + t.column("maxTootChars", .integer) + } + try db.create(table: "statusAncestorJoin") { t in t.column("parentId", .text).indexed().notNull() .references("statusRecord", onDelete: .cascade) diff --git a/DB/Sources/DB/Content/ContentDatabase.swift b/DB/Sources/DB/Content/ContentDatabase.swift index d383da4..c73a402 100644 --- a/DB/Sources/DB/Content/ContentDatabase.swift +++ b/DB/Sources/DB/Content/ContentDatabase.swift @@ -439,6 +439,12 @@ public extension ContentDatabase { .eraseToAnyPublisher() } + func insert(instance: Instance) -> AnyPublisher { + databaseWriter.writePublisher(updates: instance.save) + .ignoreOutput() + .eraseToAnyPublisher() + } + func timelinePublisher(_ timeline: Timeline) -> AnyPublisher<[CollectionSection], Error> { ValueObservation.tracking( TimelineItemsInfo.request(TimelineRecord.filter(TimelineRecord.Columns.id == timeline.id)).fetchOne) @@ -611,6 +617,16 @@ public extension ContentDatabase { .eraseToAnyPublisher() } + func instancePublisher(uri: String) -> AnyPublisher { + ValueObservation.tracking( + InstanceInfo.request(InstanceRecord.filter(InstanceRecord.Columns.uri == uri)).fetchOne) + .removeDuplicates() + .publisher(in: databaseWriter) + .compactMap { $0 } + .map(Instance.init(info:)) + .eraseToAnyPublisher() + } + func pickerEmojisPublisher() -> AnyPublisher<[Emoji], Error> { ValueObservation.tracking( Emoji.filter(Emoji.Columns.visibleInPicker == true) diff --git a/DB/Sources/DB/Content/InstanceInfo.swift b/DB/Sources/DB/Content/InstanceInfo.swift new file mode 100644 index 0000000..65b5599 --- /dev/null +++ b/DB/Sources/DB/Content/InstanceInfo.swift @@ -0,0 +1,20 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import Foundation +import GRDB + +struct InstanceInfo: Codable, Hashable, FetchableRecord { + let record: InstanceRecord + let contactAccountInfo: AccountInfo? +} + +extension InstanceInfo { + static func addingIncludes(_ request: T) -> T where T.RowDecoder == InstanceRecord { + request.including(optional: AccountInfo.addingIncludes(InstanceRecord.contactAccount) + .forKey(CodingKeys.contactAccountInfo)) + } + + static func request(_ request: QueryInterfaceRequest) -> QueryInterfaceRequest { + addingIncludes(request).asRequest(of: self) + } +} diff --git a/DB/Sources/DB/Content/InstanceRecord.swift b/DB/Sources/DB/Content/InstanceRecord.swift new file mode 100644 index 0000000..68aed44 --- /dev/null +++ b/DB/Sources/DB/Content/InstanceRecord.swift @@ -0,0 +1,63 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import Foundation +import GRDB +import Mastodon + +struct InstanceRecord: ContentDatabaseRecord, Hashable { + let uri: String + let title: String + let description: String + let shortDescription: String? + let email: String + let version: String + let languages: [String] + let registrations: Bool + let approvalRequired: Bool + let invitesEnabled: Bool + let urls: Instance.URLs + let stats: Instance.Stats + let thumbnail: URL? + let contactAccountId: Account.Id? + let maxTootChars: Int? +} + +extension InstanceRecord { + enum Columns { + static let uri = Column(CodingKeys.uri) + static let title = Column(CodingKeys.title) + static let description = Column(CodingKeys.description) + static let shortDescription = Column(CodingKeys.shortDescription) + static let email = Column(CodingKeys.email) + static let version = Column(CodingKeys.version) + static let languages = Column(CodingKeys.languages) + static let registrations = Column(CodingKeys.registrations) + static let approvalRequired = Column(CodingKeys.approvalRequired) + static let invitesEnabled = Column(CodingKeys.invitesEnabled) + static let urls = Column(CodingKeys.urls) + static let stats = Column(CodingKeys.stats) + static let thumbnail = Column(CodingKeys.thumbnail) + static let contactAccountId = Column(CodingKeys.contactAccountId) + static let maxTootChars = Column(CodingKeys.maxTootChars) + } + + static let contactAccount = belongsTo(AccountRecord.self) + + init(instance: Instance) { + self.uri = instance.uri + self.title = instance.title + self.description = instance.description + self.shortDescription = instance.shortDescription + self.email = instance.email + self.version = instance.version + self.languages = instance.languages + self.registrations = instance.registrations + self.approvalRequired = instance.approvalRequired + self.invitesEnabled = instance.invitesEnabled + self.urls = instance.urls + self.stats = instance.stats + self.thumbnail = instance.thumbnail + self.contactAccountId = instance.contactAccount?.id + self.maxTootChars = instance.maxTootChars + } +} diff --git a/DB/Sources/DB/Extensions/Instance+Extensions.swift b/DB/Sources/DB/Extensions/Instance+Extensions.swift new file mode 100644 index 0000000..71d0ead --- /dev/null +++ b/DB/Sources/DB/Extensions/Instance+Extensions.swift @@ -0,0 +1,41 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import Foundation +import GRDB +import Mastodon + +extension Instance { + func save(_ db: Database) throws { + if let contactAccount = contactAccount { + try AccountRecord(account: contactAccount).save(db) + } + + try InstanceRecord(instance: self).save(db) + } + + init(info: InstanceInfo) { + var contactAccount: Account? + + if let contactAccountInfo = info.contactAccountInfo { + contactAccount = Account(info: contactAccountInfo) + } + + self.init(record: info.record, contactAccount: contactAccount) + } +} + +private extension Instance { + init(record: InstanceRecord, contactAccount: Account?) { + self.init(uri: record.uri, + title: record.title, + description: record.description, + shortDescription: record.shortDescription, + email: record.email, + version: record.version, + urls: record.urls, + stats: record.stats, + thumbnail: record.thumbnail, + contactAccount: contactAccount, + maxTootChars: record.maxTootChars) + } +} diff --git a/Data Sources/ExploreDataSource.swift b/Data Sources/ExploreDataSource.swift new file mode 100644 index 0000000..e4cb660 --- /dev/null +++ b/Data Sources/ExploreDataSource.swift @@ -0,0 +1,70 @@ +// Copyright © 2021 Metabolist. All rights reserved. + +import Combine +import Mastodon +import UIKit +import ViewModels + +final class ExploreDataSource: UICollectionViewDiffableDataSource { + private let updateQueue = + DispatchQueue(label: "com.metabolist.metatext.explore-data-source.update-queue") + private var cancellables = Set() + + init(collectionView: UICollectionView, viewModel: ExploreViewModel) { + let tagRegistration = UICollectionView.CellRegistration { + $0.viewModel = $2 + } + + super.init(collectionView: collectionView) { + switch $2 { + case let .tag(tag): + return $0.dequeueConfiguredReusableCell( + using: tagRegistration, + for: $1, + item: viewModel.viewModel(tag: tag)) + } + } + + let headerRegistration = UICollectionView.SupplementaryRegistration + (elementKind: "Header") { [weak self] in + $0.label.text = self?.snapshot().sectionIdentifiers[$2.section].displayName + } + + supplementaryViewProvider = { + $0.dequeueConfiguredReusableSupplementary(using: headerRegistration, for: $2) + } + + viewModel.$trends.sink { [weak self] tags in + guard let self = self else { return } + + var snapshot = NSDiffableDataSourceSnapshot() + + if !tags.isEmpty { + snapshot.appendSections([.trending]) + snapshot.appendItems(tags.map(ExploreViewModel.Item.tag), toSection: .trending) + } + + self.apply(snapshot, animatingDifferences: false) + } + .store(in: &cancellables) + } + + override func apply(_ snapshot: NSDiffableDataSourceSnapshot, + animatingDifferences: Bool = true, + completion: (() -> Void)? = nil) { + updateQueue.async { + super.apply(snapshot, animatingDifferences: animatingDifferences, completion: completion) + } + } +} + +private extension ExploreViewModel.Section { + var displayName: String { + switch self { + case .trending: + return NSLocalizedString("explore.trending", comment: "") + case .instance: + return NSLocalizedString("explore.instance", comment: "") + } + } +} diff --git a/Localizations/Localizable.strings b/Localizations/Localizable.strings index e6f2fef..c036882 100644 --- a/Localizations/Localizable.strings +++ b/Localizations/Localizable.strings @@ -74,6 +74,8 @@ "emoji.system-group.objects" = "Objects"; "emoji.system-group.symbols" = "Symbols"; "emoji.system-group.flags" = "Flags"; +"explore.trending" = "Trending Now"; +"explore.instance" = "Instance"; "error" = "Error"; "favorites" = "Favorites"; "follow-requests" = "Follow Requests"; diff --git a/Mastodon/Sources/Mastodon/Entities/Instance.swift b/Mastodon/Sources/Mastodon/Entities/Instance.swift index 5ae3e03..eae95de 100644 --- a/Mastodon/Sources/Mastodon/Entities/Instance.swift +++ b/Mastodon/Sources/Mastodon/Entities/Instance.swift @@ -28,4 +28,28 @@ public struct Instance: Codable, Hashable { public let thumbnail: URL? public let contactAccount: Account? public let maxTootChars: Int? + + public init(uri: String, + title: String, + description: String, + shortDescription: String?, + email: String, + version: String, + urls: Instance.URLs, + stats: Instance.Stats, + thumbnail: URL?, + contactAccount: Account?, + maxTootChars: Int?) { + self.uri = uri + self.title = title + self.description = description + self.shortDescription = shortDescription + self.email = email + self.version = version + self.urls = urls + self.stats = stats + self.thumbnail = thumbnail + self.contactAccount = contactAccount + self.maxTootChars = maxTootChars + } } diff --git a/MastodonAPI/Sources/MastodonAPI/Endpoints/TagsEndpoint.swift b/MastodonAPI/Sources/MastodonAPI/Endpoints/TagsEndpoint.swift new file mode 100644 index 0000000..33df645 --- /dev/null +++ b/MastodonAPI/Sources/MastodonAPI/Endpoints/TagsEndpoint.swift @@ -0,0 +1,25 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import Foundation +import HTTP +import Mastodon + +public enum TagsEndpoint { + case trends +} + +extension TagsEndpoint: Endpoint { + public typealias ResultType = [Tag] + + public var pathComponentsInContext: [String] { + switch self { + case .trends: return ["trends"] + } + } + + public var method: HTTPMethod { + switch self { + case .trends: return .get + } + } +} diff --git a/Metatext.xcodeproj/project.pbxproj b/Metatext.xcodeproj/project.pbxproj index ee62b3c..2912380 100644 --- a/Metatext.xcodeproj/project.pbxproj +++ b/Metatext.xcodeproj/project.pbxproj @@ -145,6 +145,9 @@ D0D2AC5325BCD2BA003D5DF2 /* TagContentConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0D2AC5225BCD2BA003D5DF2 /* TagContentConfiguration.swift */; }; D0D2AC6725BD0484003D5DF2 /* LineChartView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0D2AC6625BD0484003D5DF2 /* LineChartView.swift */; }; D0DD50CB256B1F24004A04F7 /* ReportView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0DD50CA256B1F24004A04F7 /* ReportView.swift */; }; + D0DDA76B25C5F20800FA0F91 /* ExploreDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0DDA76A25C5F20800FA0F91 /* ExploreDataSource.swift */; }; + D0DDA77525C5F73F00FA0F91 /* TagCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0DDA77425C5F73F00FA0F91 /* TagCollectionViewCell.swift */; }; + D0DDA77F25C6058300FA0F91 /* ExploreSectionHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0DDA77E25C6058300FA0F91 /* ExploreSectionHeaderView.swift */; }; D0E1F583251F13EC00D45315 /* WebfingerIndicatorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0E1F582251F13EC00D45315 /* WebfingerIndicatorView.swift */; }; D0E2C1D124FD97F000854680 /* ViewModels in Frameworks */ = {isa = PBXBuildFile; productRef = D0E2C1D024FD97F000854680 /* ViewModels */; }; D0E5361C24E3EB4D00FB1CE1 /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0E5361B24E3EB4D00FB1CE1 /* NotificationService.swift */; }; @@ -332,6 +335,9 @@ D0D2AC6625BD0484003D5DF2 /* LineChartView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LineChartView.swift; sourceTree = ""; }; D0D7C013250440610039AD6F /* CodableBloomFilter */ = {isa = PBXFileReference; lastKnownFileType = folder; path = CodableBloomFilter; sourceTree = ""; }; D0DD50CA256B1F24004A04F7 /* ReportView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportView.swift; sourceTree = ""; }; + D0DDA76A25C5F20800FA0F91 /* ExploreDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExploreDataSource.swift; sourceTree = ""; }; + D0DDA77425C5F73F00FA0F91 /* TagCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagCollectionViewCell.swift; sourceTree = ""; }; + D0DDA77E25C6058300FA0F91 /* ExploreSectionHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExploreSectionHeaderView.swift; sourceTree = ""; }; D0E0F1E424FC49FC002C04BF /* Mastodon */ = {isa = PBXFileReference; lastKnownFileType = folder; path = Mastodon; sourceTree = ""; }; D0E1F582251F13EC00D45315 /* WebfingerIndicatorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebfingerIndicatorView.swift; sourceTree = ""; }; D0E2C1CF24FD8BA400854680 /* ViewModels */ = {isa = PBXFileReference; lastKnownFileType = folder; path = ViewModels; sourceTree = ""; }; @@ -415,6 +421,7 @@ D007023D25562A2800F38136 /* ConversationAvatarsView.swift */, D05936DD25A937EC00754FDF /* EditThumbnailView.swift */, D07EC7FC25B16994006DF726 /* EmojiCategoryHeaderView.swift */, + D0DDA77E25C6058300FA0F91 /* ExploreSectionHeaderView.swift */, D0D2AC6625BD0484003D5DF2 /* LineChartView.swift */, D0BEB1F624F9A84B001B0F04 /* LoadingTableFooterView.swift */, D05936FE25AA94EA00754FDF /* MarkAttachmentsSensitiveView.swift */, @@ -485,6 +492,7 @@ isa = PBXGroup; children = ( D07EC7DB25B13DBB006DF726 /* EmojiCollectionViewCell.swift */, + D0DDA77425C5F73F00FA0F91 /* TagCollectionViewCell.swift */, ); path = "Collection View Cells"; sourceTree = ""; @@ -610,6 +618,7 @@ D0A1F4F5252E7D2A004435BF /* Data Sources */ = { isa = PBXGroup; children = ( + D0DDA76A25C5F20800FA0F91 /* ExploreDataSource.swift */, D021A5FF25C3478F008A0C0D /* IdentitiesDataSource.swift */, D0A1F4F6252E7D4B004435BF /* TableViewDataSource.swift */, ); @@ -934,6 +943,7 @@ D036AA07254B6118009094DF /* NotificationView.swift in Sources */, D08E52EE257D757100FA2C5F /* CompositionView.swift in Sources */, D0E569E0252931B100FA1D72 /* LoadMoreContentConfiguration.swift in Sources */, + D0DDA77525C5F73F00FA0F91 /* TagCollectionViewCell.swift in Sources */, D0FE1C9825368A9D003EF1EB /* PlayerCache.swift in Sources */, D05936DE25A937EC00754FDF /* EditThumbnailView.swift in Sources */, D0F0B136251AA12700942152 /* CollectionItem+Extensions.swift in Sources */, @@ -1021,6 +1031,7 @@ D025B17E25C500BC001C69A8 /* CapsuleButton.swift in Sources */, D0C7D4C424F7616A001EBDBB /* AppDelegate.swift in Sources */, D0C7D49924F7616A001EBDBB /* AddIdentityView.swift in Sources */, + D0DDA77F25C6058300FA0F91 /* ExploreSectionHeaderView.swift in Sources */, D0FCC105259C4E61000B67DF /* NewStatusViewController.swift in Sources */, D087671625BAA8C0001FDD43 /* ExploreViewController.swift in Sources */, D0D2AC6725BD0484003D5DF2 /* LineChartView.swift in Sources */, @@ -1029,6 +1040,7 @@ D097F4C125BFA04C00859F2C /* NotificationsViewController.swift in Sources */, D0E1F583251F13EC00D45315 /* WebfingerIndicatorView.swift in Sources */, D0BEB20524FA1107001B0F04 /* FiltersView.swift in Sources */, + D0DDA76B25C5F20800FA0F91 /* ExploreDataSource.swift in Sources */, D035F88725B8016000DC75ED /* NavigationViewModel+Extensions.swift in Sources */, D0C7D49B24F7616A001EBDBB /* PreferencesView.swift in Sources */, D088406D25AFBBE200BB749B /* EmojiPickerViewController.swift in Sources */, diff --git a/ServiceLayer/Sources/ServiceLayer/Services/ExploreService.swift b/ServiceLayer/Sources/ServiceLayer/Services/ExploreService.swift index e5b3d88..300a5b8 100644 --- a/ServiceLayer/Sources/ServiceLayer/Services/ExploreService.swift +++ b/ServiceLayer/Sources/ServiceLayer/Services/ExploreService.swift @@ -7,12 +7,15 @@ import Mastodon import MastodonAPI public struct ExploreService { + public let navigationService: NavigationService + private let mastodonAPIClient: MastodonAPIClient private let contentDatabase: ContentDatabase init(mastodonAPIClient: MastodonAPIClient, contentDatabase: ContentDatabase) { self.mastodonAPIClient = mastodonAPIClient self.contentDatabase = contentDatabase + navigationService = NavigationService(mastodonAPIClient: mastodonAPIClient, contentDatabase: contentDatabase) } } @@ -20,4 +23,14 @@ public extension ExploreService { func searchService() -> SearchService { SearchService(mastodonAPIClient: mastodonAPIClient, contentDatabase: contentDatabase) } + + func instanceServicePublisher(uri: String) -> AnyPublisher { + contentDatabase.instancePublisher(uri: uri) + .map { InstanceService(instance: $0, mastodonAPIClient: mastodonAPIClient) } + .eraseToAnyPublisher() + } + + func fetchTrends() -> AnyPublisher<[Tag], Error> { + mastodonAPIClient.request(TagsEndpoint.trends) + } } diff --git a/ServiceLayer/Sources/ServiceLayer/Services/IdentityService.swift b/ServiceLayer/Sources/ServiceLayer/Services/IdentityService.swift index 1f846be..6f87acb 100644 --- a/ServiceLayer/Sources/ServiceLayer/Services/IdentityService.swift +++ b/ServiceLayer/Sources/ServiceLayer/Services/IdentityService.swift @@ -64,7 +64,10 @@ public extension IdentityService { func refreshInstance() -> AnyPublisher { mastodonAPIClient.request(InstanceEndpoint.instance) - .flatMap { identityDatabase.updateInstance($0, id: id) } + .flatMap { + identityDatabase.updateInstance($0, id: id) + .merge(with: contentDatabase.insert(instance: $0)) + } .eraseToAnyPublisher() } diff --git a/ServiceLayer/Sources/ServiceLayer/Services/InstanceService.swift b/ServiceLayer/Sources/ServiceLayer/Services/InstanceService.swift new file mode 100644 index 0000000..32207d5 --- /dev/null +++ b/ServiceLayer/Sources/ServiceLayer/Services/InstanceService.swift @@ -0,0 +1,17 @@ +// Copyright © 2021 Metabolist. All rights reserved. + +import Combine +import Foundation +import Mastodon +import MastodonAPI + +public struct InstanceService { + public let instance: Instance + + private let mastodonAPIClient: MastodonAPIClient + + init(instance: Instance, mastodonAPIClient: MastodonAPIClient) { + self.instance = instance + self.mastodonAPIClient = mastodonAPIClient + } +} diff --git a/View Controllers/ExploreViewController.swift b/View Controllers/ExploreViewController.swift index 1586e26..f7d036a 100644 --- a/View Controllers/ExploreViewController.swift +++ b/View Controllers/ExploreViewController.swift @@ -9,11 +9,15 @@ final class ExploreViewController: UICollectionViewController { private let rootViewModel: RootViewModel private var cancellables = Set() + private lazy var dataSource: ExploreDataSource = { + .init(collectionView: collectionView, viewModel: viewModel) + }() + init(viewModel: ExploreViewModel, rootViewModel: RootViewModel) { self.viewModel = viewModel self.rootViewModel = rootViewModel - super.init(collectionViewLayout: UICollectionViewFlowLayout()) + super.init(collectionViewLayout: Self.layout()) tabBarItem = UITabBarItem( title: NSLocalizedString("main-navigation.explore", comment: ""), @@ -29,6 +33,16 @@ final class ExploreViewController: UICollectionViewController { override func viewDidLoad() { super.viewDidLoad() + collectionView.dataSource = dataSource + collectionView.backgroundColor = .systemBackground + clearsSelectionOnViewWillAppear = true + + collectionView.refreshControl = UIRefreshControl() + collectionView.refreshControl?.addAction( + UIAction { [weak self] _ in + self?.viewModel.refresh() }, + for: .valueChanged) + navigationItem.title = NSLocalizedString("main-navigation.explore", comment: "") let searchResultsController = TableViewController( @@ -43,6 +57,19 @@ final class ExploreViewController: UICollectionViewController { searchController.searchResultsUpdater = self navigationItem.searchController = searchController + viewModel.events.sink { [weak self] in self?.handle(event: $0) }.store(in: &cancellables) + + viewModel.$loading.sink { [weak self] in + guard let self = self else { return } + + let refreshControlVisibile = self.collectionView.refreshControl?.isRefreshing ?? false + + if !$0, refreshControlVisibile { + self.collectionView.refreshControl?.endRefreshing() + } + } + .store(in: &cancellables) + viewModel.searchViewModel.events.sink { [weak self] in if case let .navigation(navigation) = $0, case let .searchScope(scope) = navigation { @@ -52,6 +79,18 @@ final class ExploreViewController: UICollectionViewController { } .store(in: &cancellables) } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + viewModel.refresh() + } + + override func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + guard let item = dataSource.itemIdentifier(for: indexPath) else { return } + + viewModel.select(item: item) + } } extension ExploreViewController: UISearchResultsUpdating { @@ -68,3 +107,36 @@ extension ExploreViewController: UISearchResultsUpdating { viewModel.searchViewModel.query = searchController.searchBar.text ?? "" } } + +private extension ExploreViewController { + static func layout() -> UICollectionViewLayout { + var config = UICollectionLayoutListConfiguration(appearance: .plain) + + config.headerMode = .supplementary + + return UICollectionViewCompositionalLayout.list(using: config) + } + + func handle(event: ExploreViewModel.Event) { + switch event { + case let .navigation(navigation): + handle(navigation: navigation) + } + } + + func handle(navigation: Navigation) { + switch navigation { + case let .collection(collectionService): + let vc = TableViewController( + viewModel: CollectionItemsViewModel( + collectionService: collectionService, + identityContext: viewModel.identityContext), + rootViewModel: rootViewModel, + parentNavigationController: nil) + + show(vc, sender: self) + default: + break + } + } +} diff --git a/ViewModels/Sources/ViewModels/View Models/ExploreViewModel.swift b/ViewModels/Sources/ViewModels/View Models/ExploreViewModel.swift index 6f92081..f372913 100644 --- a/ViewModels/Sources/ViewModels/View Models/ExploreViewModel.swift +++ b/ViewModels/Sources/ViewModels/View Models/ExploreViewModel.swift @@ -1,13 +1,22 @@ // Copyright © 2020 Metabolist. All rights reserved. +import Combine import Foundation +import Mastodon import ServiceLayer public final class ExploreViewModel: ObservableObject { public let searchViewModel: SearchViewModel + public let events: AnyPublisher + @Published public var instanceViewModel: InstanceViewModel? + @Published public var trends = [Tag]() + @Published public private(set) var loading = false + @Published public var alertItem: AlertItem? public let identityContext: IdentityContext private let exploreService: ExploreService + private let eventsSubject = PassthroughSubject() + private var cancellables = Set() init(service: ExploreService, identityContext: IdentityContext) { exploreService = service @@ -15,5 +24,60 @@ public final class ExploreViewModel: ObservableObject { searchViewModel = SearchViewModel( searchService: exploreService.searchService(), identityContext: identityContext) + events = eventsSubject.eraseToAnyPublisher() + + identityContext.$identity + .compactMap { $0.instance?.uri } + .removeDuplicates() + .flatMap { service.instanceServicePublisher(uri: $0) } + .map { InstanceViewModel(instanceService: $0) } + .receive(on: DispatchQueue.main) + .assignErrorsToAlertItem(to: \.alertItem, on: self) + .assign(to: &$instanceViewModel) + } +} + +public extension ExploreViewModel { + enum Event { + case navigation(Navigation) + } + + enum Section: Hashable { + case trending + case instance + } + + enum Item: Hashable { + case tag(Tag) + } + + func refresh() { + exploreService.fetchTrends() + .handleEvents(receiveOutput: { [weak self] trends in + DispatchQueue.main.async { + self?.trends = trends + } + }) + .ignoreOutput() + .merge(with: identityContext.service.refreshInstance()) + .receive(on: DispatchQueue.main) + .handleEvents(receiveSubscription: { [weak self] _ in self?.loading = true }, + receiveCompletion: { [weak self] _ in self?.loading = false }) + .sink { _ in } receiveValue: { _ in } + .store(in: &cancellables) + } + + func viewModel(tag: Tag) -> TagViewModel { + .init(tag: tag) + } + + func select(item: ExploreViewModel.Item) { + switch item { + case let .tag(tag): + eventsSubject.send( + .navigation(.collection(exploreService + .navigationService + .timelineService(timeline: .tag(tag.name))))) + } } } diff --git a/ViewModels/Sources/ViewModels/View Models/InstanceViewModel.swift b/ViewModels/Sources/ViewModels/View Models/InstanceViewModel.swift new file mode 100644 index 0000000..e9697d1 --- /dev/null +++ b/ViewModels/Sources/ViewModels/View Models/InstanceViewModel.swift @@ -0,0 +1,12 @@ +// Copyright © 2021 Metabolist. All rights reserved. + +import Foundation +import ServiceLayer + +public final class InstanceViewModel: ObservableObject { + private let instanceService: InstanceService + + init(instanceService: InstanceService) { + self.instanceService = instanceService + } +} diff --git a/ViewModels/Sources/ViewModels/View Models/NavigationViewModel.swift b/ViewModels/Sources/ViewModels/View Models/NavigationViewModel.swift index 0b56d0a..45b15dc 100644 --- a/ViewModels/Sources/ViewModels/View Models/NavigationViewModel.swift +++ b/ViewModels/Sources/ViewModels/View Models/NavigationViewModel.swift @@ -121,7 +121,7 @@ public extension NavigationViewModel { service: identityContext.service.exploreService(), identityContext: identityContext) - // TODO: initial request + exploreViewModel.refresh() return exploreViewModel } diff --git a/Views/UIKit/Collection View Cells/TagCollectionViewCell.swift b/Views/UIKit/Collection View Cells/TagCollectionViewCell.swift new file mode 100644 index 0000000..2ff2f08 --- /dev/null +++ b/Views/UIKit/Collection View Cells/TagCollectionViewCell.swift @@ -0,0 +1,35 @@ +// Copyright © 2021 Metabolist. All rights reserved. + +import UIKit +import ViewModels + +final class TagCollectionViewCell: UICollectionViewListCell { + var viewModel: TagViewModel? + + override func updateConfiguration(using state: UICellConfigurationState) { + guard let viewModel = viewModel else { return } + + contentConfiguration = TagContentConfiguration(viewModel: viewModel).updated(for: state) + updateConstraintsIfNeeded() + } + + override func updateConstraints() { + super.updateConstraints() + + let separatorLeadingAnchor: NSLayoutXAxisAnchor + let separatorTrailingAnchor: NSLayoutXAxisAnchor + + if UIDevice.current.userInterfaceIdiom == .pad { + separatorLeadingAnchor = readableContentGuide.leadingAnchor + separatorTrailingAnchor = readableContentGuide.trailingAnchor + } else { + separatorLeadingAnchor = leadingAnchor + separatorTrailingAnchor = trailingAnchor + } + + NSLayoutConstraint.activate([ + separatorLayoutGuide.leadingAnchor.constraint(equalTo: separatorLeadingAnchor), + separatorLayoutGuide.trailingAnchor.constraint(equalTo: separatorTrailingAnchor) + ]) + } +} diff --git a/Views/UIKit/ExploreSectionHeaderView.swift b/Views/UIKit/ExploreSectionHeaderView.swift new file mode 100644 index 0000000..99e3680 --- /dev/null +++ b/Views/UIKit/ExploreSectionHeaderView.swift @@ -0,0 +1,44 @@ +// Copyright © 2021 Metabolist. All rights reserved. + +import UIKit + +final class ExploreSectionHeaderView: UICollectionReusableView { + let label = UILabel() + + override init(frame: CGRect) { + super.init(frame: frame) + + initialSetup() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +private extension ExploreSectionHeaderView { + func initialSetup() { + backgroundColor = .systemBackground + + addSubview(label) + label.translatesAutoresizingMaskIntoConstraints = false + label.adjustsFontForContentSizeCategory = true + label.font = .preferredFont(forTextStyle: .headline) + + let layoutGuide: UILayoutGuide + + if UIDevice.current.userInterfaceIdiom == .pad { + layoutGuide = readableContentGuide + } else { + layoutGuide = layoutMarginsGuide + } + + NSLayoutConstraint.activate([ + label.leadingAnchor.constraint(equalTo: layoutGuide.leadingAnchor), + label.topAnchor.constraint(equalTo: layoutGuide.topAnchor), + label.trailingAnchor.constraint(equalTo: layoutGuide.trailingAnchor), + label.bottomAnchor.constraint(equalTo: layoutGuide.bottomAnchor) + ]) + } +}