diff --git a/DB/Sources/DB/Content/ContentDatabase.swift b/DB/Sources/DB/Content/ContentDatabase.swift index 0478f6c..7fa3426 100644 --- a/DB/Sources/DB/Content/ContentDatabase.swift +++ b/DB/Sources/DB/Content/ContentDatabase.swift @@ -215,7 +215,7 @@ public extension ContentDatabase { .eraseToAnyPublisher() } - func observation(timeline: Timeline) -> AnyPublisher<[[Timeline.Item]], Error> { + func observation(timeline: Timeline) -> AnyPublisher<[[CollectionItem]], Error> { ValueObservation.tracking( TimelineItemsInfo.request(TimelineRecord.filter(TimelineRecord.Columns.id == timeline.id)).fetchOne) .removeDuplicates() @@ -225,7 +225,7 @@ public extension ContentDatabase { .eraseToAnyPublisher() } - func contextObservation(parentID: String) -> AnyPublisher<[[Timeline.Item]], Error> { + func contextObservation(parentID: String) -> AnyPublisher<[[CollectionItem]], Error> { ValueObservation.tracking( ContextItemsInfo.request(StatusRecord.filter(StatusRecord.Columns.id == parentID)).fetchOne) .removeDuplicates() diff --git a/DB/Sources/DB/Content/ContextItemsInfo.swift b/DB/Sources/DB/Content/ContextItemsInfo.swift index ee1705c..de477c6 100644 --- a/DB/Sources/DB/Content/ContextItemsInfo.swift +++ b/DB/Sources/DB/Content/ContextItemsInfo.swift @@ -21,7 +21,7 @@ extension ContextItemsInfo { addingIncludes(request).asRequest(of: self) } - func items(filters: [Filter]) -> [[Timeline.Item]] { + func items(filters: [Filter]) -> [[CollectionItem]] { let regularExpression = filters.regularExpression(context: .thread) return [ancestors, [parent], descendants].map { section in diff --git a/DB/Sources/DB/Content/TimelineItemsInfo.swift b/DB/Sources/DB/Content/TimelineItemsInfo.swift index 559e9e8..9657212 100644 --- a/DB/Sources/DB/Content/TimelineItemsInfo.swift +++ b/DB/Sources/DB/Content/TimelineItemsInfo.swift @@ -28,11 +28,11 @@ extension TimelineItemsInfo { addingIncludes(request).asRequest(of: self) } - func items(filters: [Filter]) -> [[Timeline.Item]] { + func items(filters: [Filter]) -> [[CollectionItem]] { let timeline = Timeline(record: timelineRecord)! let filterRegularExpression = filters.regularExpression(context: timeline.filterContext) var timelineItems = statusInfos.filtered(regularExpression: filterRegularExpression) - .map { Timeline.Item.status(.init(status: .init(info: $0))) } + .map { CollectionItem.status(.init(status: .init(info: $0))) } for loadMoreRecord in loadMoreRecords { guard let index = timelineItems.firstIndex(where: { @@ -51,7 +51,7 @@ extension TimelineItemsInfo { if let pinnedStatusInfos = pinnedStatusesInfo?.pinnedStatusInfos { return [pinnedStatusInfos.filtered(regularExpression: filterRegularExpression) - .map { Timeline.Item.status(.init(status: .init(info: $0), pinned: true)) }, + .map { CollectionItem.status(.init(status: .init(info: $0), pinned: true)) }, timelineItems] } else { return [timelineItems] diff --git a/DB/Sources/DB/Entities/CollectionItem.swift b/DB/Sources/DB/Entities/CollectionItem.swift new file mode 100644 index 0000000..0931fd7 --- /dev/null +++ b/DB/Sources/DB/Entities/CollectionItem.swift @@ -0,0 +1,25 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import Mastodon + +public enum CollectionItem: Hashable { + case status(StatusConfiguration) + case loadMore(LoadMore) + case account(Account) +} + +public extension CollectionItem { + struct StatusConfiguration: Hashable { + public let status: Status + public let pinned: Bool + public let isReplyInContext: Bool + public let hasReplyFollowing: Bool + + init(status: Status, pinned: Bool = false, isReplyInContext: Bool = false, hasReplyFollowing: Bool = false) { + self.status = status + self.pinned = pinned + self.isReplyInContext = isReplyInContext + self.hasReplyFollowing = hasReplyFollowing + } + } +} diff --git a/DB/Sources/DB/Entities/Timeline.swift b/DB/Sources/DB/Entities/Timeline.swift index a01ff3f..6957668 100644 --- a/DB/Sources/DB/Entities/Timeline.swift +++ b/DB/Sources/DB/Entities/Timeline.swift @@ -16,11 +16,6 @@ public extension Timeline { static let unauthenticatedDefaults: [Timeline] = [.local, .federated] static let authenticatedDefaults: [Timeline] = [.home, .local, .federated] - enum Item: Hashable { - case status(StatusConfiguration) - case loadMore(LoadMore) - } - var filterContext: Filter.Context { switch self { case .home, .list: @@ -33,22 +28,6 @@ public extension Timeline { } } -public extension Timeline.Item { - struct StatusConfiguration: Hashable { - public let status: Status - public let pinned: Bool - public let isReplyInContext: Bool - public let hasReplyFollowing: Bool - - init(status: Status, pinned: Bool = false, isReplyInContext: Bool = false, hasReplyFollowing: Bool = false) { - self.status = status - self.pinned = pinned - self.isReplyInContext = isReplyInContext - self.hasReplyFollowing = hasReplyFollowing - } - } -} - extension Timeline: Identifiable { public var id: String { switch self { diff --git a/ServiceLayer/Sources/ServiceLayer/Entities/CollectionItem.swift b/ServiceLayer/Sources/ServiceLayer/Entities/CollectionItem.swift new file mode 100644 index 0000000..546d6ae --- /dev/null +++ b/ServiceLayer/Sources/ServiceLayer/Entities/CollectionItem.swift @@ -0,0 +1,5 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import DB + +public typealias CollectionItem = DB.CollectionItem diff --git a/ServiceLayer/Sources/ServiceLayer/Services/AccountListService.swift b/ServiceLayer/Sources/ServiceLayer/Services/AccountListService.swift index 54a5303..abc5dcf 100644 --- a/ServiceLayer/Sources/ServiceLayer/Services/AccountListService.swift +++ b/ServiceLayer/Sources/ServiceLayer/Services/AccountListService.swift @@ -6,8 +6,8 @@ import Foundation import Mastodon import MastodonAPI -public struct AccountListService { - public let accountSections: AnyPublisher<[[Account]], Error> +public struct AccountListService: CollectionService { + public let sections: AnyPublisher<[[CollectionItem]], Error> public let nextPageMaxIDs: AnyPublisher public let navigationService: NavigationService @@ -29,7 +29,9 @@ extension AccountListService { let nextPageMaxIDsSubject = PassthroughSubject() self.init( - accountSections: contentDatabase.accountListObservation(list).map { [$0] }.eraseToAnyPublisher(), + sections: contentDatabase.accountListObservation(list) + .map { [$0.map { CollectionItem.account($0) }] } + .eraseToAnyPublisher(), nextPageMaxIDs: nextPageMaxIDsSubject.eraseToAnyPublisher(), navigationService: NavigationService( status: nil, diff --git a/ServiceLayer/Sources/ServiceLayer/Services/CollectionService.swift b/ServiceLayer/Sources/ServiceLayer/Services/CollectionService.swift new file mode 100644 index 0000000..b584588 --- /dev/null +++ b/ServiceLayer/Sources/ServiceLayer/Services/CollectionService.swift @@ -0,0 +1,17 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import Combine + +public protocol CollectionService { + var sections: AnyPublisher<[[CollectionItem]], Error> { get } + var nextPageMaxIDs: AnyPublisher { get } + var navigationService: NavigationService { get } + var title: String? { get } + var contextParentID: String? { get } + func request(maxID: String?, minID: String?) -> AnyPublisher +} + +extension CollectionService { + public var title: String? { nil } + public var contextParentID: String? { nil } +} diff --git a/ServiceLayer/Sources/ServiceLayer/Services/NavigationService.swift b/ServiceLayer/Sources/ServiceLayer/Services/NavigationService.swift index b17c77e..25a96cd 100644 --- a/ServiceLayer/Sources/ServiceLayer/Services/NavigationService.swift +++ b/ServiceLayer/Sources/ServiceLayer/Services/NavigationService.swift @@ -8,7 +8,7 @@ import MastodonAPI public enum Navigation { case url(URL) - case statusList(StatusListService) + case collection(CollectionService) case profile(ProfileService) case webfingerStart case webfingerEnd @@ -30,7 +30,7 @@ public extension NavigationService { func item(url: URL) -> AnyPublisher { if let tag = tag(url: url) { return Just( - .statusList( + .collection( StatusListService( timeline: .tag(tag), mastodonAPIClient: mastodonAPIClient, @@ -40,7 +40,7 @@ public extension NavigationService { return Just(.profile(profileService(id: accountID))).eraseToAnyPublisher() } else if mastodonAPIClient.instanceURL.host == url.host, let statusID = url.statusID { return Just( - .statusList( + .collection( StatusListService( statusID: statusID, mastodonAPIClient: mastodonAPIClient, @@ -112,7 +112,7 @@ private extension NavigationService { receiveCompletion: { _ in navigationSubject.send(.webfingerEnd) }) .map { results -> Navigation in if let tag = results.hashtags.first { - return .statusList( + return .collection( StatusListService( timeline: .tag(tag.name), mastodonAPIClient: mastodonAPIClient, @@ -120,7 +120,7 @@ private extension NavigationService { } else if let account = results.accounts.first { return .profile(profileService(account: account)) } else if let status = results.statuses.first { - return .statusList( + return .collection( StatusListService( statusID: status.id, mastodonAPIClient: mastodonAPIClient, diff --git a/ServiceLayer/Sources/ServiceLayer/Services/StatusListService.swift b/ServiceLayer/Sources/ServiceLayer/Services/StatusListService.swift index 1c29cda..0e01715 100644 --- a/ServiceLayer/Sources/ServiceLayer/Services/StatusListService.swift +++ b/ServiceLayer/Sources/ServiceLayer/Services/StatusListService.swift @@ -6,8 +6,8 @@ import Foundation import Mastodon import MastodonAPI -public struct StatusListService { - public let sections: AnyPublisher<[[Timeline.Item]], Error> +public struct StatusListService: CollectionService { + public let sections: AnyPublisher<[[CollectionItem]], Error> public let nextPageMaxIDs: AnyPublisher public let contextParentID: String? public let title: String? diff --git a/View Controllers/TableViewController.swift b/View Controllers/TableViewController.swift index c225aca..3f6f0fa 100644 --- a/View Controllers/TableViewController.swift +++ b/View Controllers/TableViewController.swift @@ -15,11 +15,13 @@ class TableViewController: UITableViewController { DispatchQueue(label: "com.metabolist.metatext.collection.data-source-queue") private lazy var dataSource: UITableViewDiffableDataSource = { - UITableViewDiffableDataSource(tableView: tableView) { [weak self] tableView, indexPath, item in - guard let self = self, let cellViewModel = self.viewModel.viewModel(item: item) else { return nil } + UITableViewDiffableDataSource(tableView: tableView) { [weak self] tableView, indexPath, identifier in + guard let self = self, + let cellViewModel = self.viewModel.viewModel(identifier: identifier) + else { return nil } let cell = tableView.dequeueReusableCell( - withIdentifier: String(describing: item.kind.cellClass), + withIdentifier: String(describing: identifier.kind.cellClass), for: indexPath) switch (cell, cellViewModel) { @@ -111,17 +113,17 @@ class TableViewController: UITableViewController { } override func tableView(_ tableView: UITableView, shouldHighlightRowAt indexPath: IndexPath) -> Bool { - guard let item = dataSource.itemIdentifier(for: indexPath) else { return true } + guard let identifier = dataSource.itemIdentifier(for: indexPath) else { return true } - return viewModel.canSelect(item: item) + return viewModel.canSelect(identifier: identifier) } override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { tableView.deselectRow(at: indexPath, animated: true) - guard let item = dataSource.itemIdentifier(for: indexPath) else { return } + guard let identifier = dataSource.itemIdentifier(for: indexPath) else { return } - viewModel.itemSelected(item) + viewModel.select(identifier: identifier) } override func viewDidLayoutSubviews() { @@ -185,7 +187,7 @@ private extension TableViewController { func setupViewModelBindings() { viewModel.title.sink { [weak self] in self?.navigationItem.title = $0 }.store(in: &cancellables) - viewModel.collectionItems.sink { [weak self] in self?.update(items: $0) }.store(in: &cancellables) + viewModel.sections.sink { [weak self] in self?.update(items: $0) }.store(in: &cancellables) viewModel.navigationEvents.receive(on: DispatchQueue.main).sink { [weak self] in guard let self = self else { return } diff --git a/ViewModels/Sources/ViewModels/AccountListViewModel.swift b/ViewModels/Sources/ViewModels/AccountListViewModel.swift index ae5de75..c8c0259 100644 --- a/ViewModels/Sources/ViewModels/AccountListViewModel.swift +++ b/ViewModels/Sources/ViewModels/AccountListViewModel.swift @@ -1,130 +1,130 @@ // Copyright © 2020 Metabolist. All rights reserved. -import Combine -import Foundation -import Mastodon -import ServiceLayer - -public final class AccountListViewModel: ObservableObject { - @Published public private(set) var items = [[CollectionItemIdentifier]]() - @Published public var alertItem: AlertItem? - public let navigationEvents: AnyPublisher - public private(set) var nextPageMaxID: String? - - private let accountListService: AccountListService - private var accounts = [String: Account]() - private var accountViewModelCache = [Account: (AccountViewModel, AnyCancellable)]() - private let navigationEventsSubject = PassthroughSubject() - private let loadingSubject = PassthroughSubject() - private var cancellables = Set() - - init(accountListService: AccountListService) { - self.accountListService = accountListService - navigationEvents = navigationEventsSubject.eraseToAnyPublisher() - - accountListService.accountSections - .handleEvents(receiveOutput: { [weak self] in - self?.cleanViewModelCache(newAccountSections: $0) - self?.accounts = Dictionary(uniqueKeysWithValues: Set($0.reduce([], +)).map { ($0.id, $0) }) - }) - .map { $0.map { $0.map(CollectionItemIdentifier.init(account:)) } } - .receive(on: DispatchQueue.main) - .assignErrorsToAlertItem(to: \.alertItem, on: self) - .assign(to: &$items) - - accountListService.nextPageMaxIDs - .sink { [weak self] in self?.nextPageMaxID = $0 } - .store(in: &cancellables) - } -} - -extension AccountListViewModel: CollectionViewModel { - public var collectionItems: AnyPublisher<[[CollectionItemIdentifier]], Never> { $items.eraseToAnyPublisher() } - - public var title: AnyPublisher { Just(nil).eraseToAnyPublisher() } - - public var alertItems: AnyPublisher { $alertItem.compactMap { $0 }.eraseToAnyPublisher() } - - public var loading: AnyPublisher { loadingSubject.eraseToAnyPublisher() } - - public var maintainScrollPositionOfItem: CollectionItemIdentifier? { - nil - } - - public func request(maxID: String?, minID: String?) { - accountListService.request(maxID: maxID, minID: minID) - .receive(on: DispatchQueue.main) - .assignErrorsToAlertItem(to: \.alertItem, on: self) - .handleEvents( - receiveSubscription: { [weak self] _ in self?.loadingSubject.send(true) }, - receiveCompletion: { [weak self] _ in self?.loadingSubject.send(false) }) - .sink { _ in } - .store(in: &cancellables) - } - - public func itemSelected(_ item: CollectionItemIdentifier) { - switch item.kind { - case .account: - let navigationService = accountListService.navigationService - let profileService: ProfileService - - if let account = accounts[item.id] { - profileService = navigationService.profileService(account: account) - } else { - profileService = navigationService.profileService(id: item.id) - } - - navigationEventsSubject.send(.profileNavigation(ProfileViewModel(profileService: profileService))) - default: - break - } - } - - public func canSelect(item: CollectionItemIdentifier) -> Bool { - true - } - - public func viewModel(item: CollectionItemIdentifier) -> Any? { - switch item.kind { - case .account: - return accountViewModel(id: item.id) - default: - return nil - } - } -} - -private extension AccountListViewModel { - func accountViewModel(id: String) -> AccountViewModel? { - guard let account = accounts[id] else { return nil } - - var accountViewModel: AccountViewModel - - if let cachedViewModel = accountViewModelCache[account]?.0 { - accountViewModel = cachedViewModel - } else { - accountViewModel = AccountViewModel( - accountService: accountListService.navigationService.accountService(account: account)) - accountViewModelCache[account] = (accountViewModel, - accountViewModel.events - .flatMap { $0 } - .assignErrorsToAlertItem(to: \.alertItem, on: self) - .sink { [weak self] in - guard - let self = self, - let event = NavigationEvent($0) - else { return } - - self.navigationEventsSubject.send(event) - }) - } - - return accountViewModel - } - - func cleanViewModelCache(newAccountSections: [[Account]]) { - let newAccounts = Set(newAccountSections.reduce([], +)) - - accountViewModelCache = accountViewModelCache.filter { newAccounts.contains($0.key) } - } -} +//import Combine +//import Foundation +//import Mastodon +//import ServiceLayer +// +//public final class AccountListViewModel: ObservableObject { +// @Published public private(set) var items = [[CollectionItemIdentifier]]() +// @Published public var alertItem: AlertItem? +// public let navigationEvents: AnyPublisher +// public private(set) var nextPageMaxID: String? +// +// private let accountListService: AccountListService +// private var accounts = [String: Account]() +// private var accountViewModelCache = [Account: (AccountViewModel, AnyCancellable)]() +// private let navigationEventsSubject = PassthroughSubject() +// private let loadingSubject = PassthroughSubject() +// private var cancellables = Set() +// +// init(accountListService: AccountListService) { +// self.accountListService = accountListService +// navigationEvents = navigationEventsSubject.eraseToAnyPublisher() +// +// accountListService.accountSections +// .handleEvents(receiveOutput: { [weak self] in +// self?.cleanViewModelCache(newAccountSections: $0) +// self?.accounts = Dictionary(uniqueKeysWithValues: Set($0.reduce([], +)).map { ($0.id, $0) }) +// }) +// .map { $0.map { $0.map(CollectionItemIdentifier.init(account:)) } } +// .receive(on: DispatchQueue.main) +// .assignErrorsToAlertItem(to: \.alertItem, on: self) +// .assign(to: &$items) +// +// accountListService.nextPageMaxIDs +// .sink { [weak self] in self?.nextPageMaxID = $0 } +// .store(in: &cancellables) +// } +//} +// +//extension AccountListViewModel: CollectionViewModel { +// public var collectionItems: AnyPublisher<[[CollectionItemIdentifier]], Never> { $items.eraseToAnyPublisher() } +// +// public var title: AnyPublisher { Just(nil).eraseToAnyPublisher() } +// +// public var alertItems: AnyPublisher { $alertItem.compactMap { $0 }.eraseToAnyPublisher() } +// +// public var loading: AnyPublisher { loadingSubject.eraseToAnyPublisher() } +// +// public var maintainScrollPositionOfItem: CollectionItemIdentifier? { +// nil +// } +// +// public func request(maxID: String?, minID: String?) { +// accountListService.request(maxID: maxID, minID: minID) +// .receive(on: DispatchQueue.main) +// .assignErrorsToAlertItem(to: \.alertItem, on: self) +// .handleEvents( +// receiveSubscription: { [weak self] _ in self?.loadingSubject.send(true) }, +// receiveCompletion: { [weak self] _ in self?.loadingSubject.send(false) }) +// .sink { _ in } +// .store(in: &cancellables) +// } +// +// public func itemSelected(_ item: CollectionItemIdentifier) { +// switch item.kind { +// case .account: +// let navigationService = accountListService.navigationService +// let profileService: ProfileService +// +// if let account = accounts[item.id] { +// profileService = navigationService.profileService(account: account) +// } else { +// profileService = navigationService.profileService(id: item.id) +// } +// +// navigationEventsSubject.send(.profileNavigation(ProfileViewModel(profileService: profileService))) +// default: +// break +// } +// } +// +// public func canSelect(item: CollectionItemIdentifier) -> Bool { +// true +// } +// +// public func viewModel(item: CollectionItemIdentifier) -> CollectionItemViewModel? { +// switch item.kind { +// case .account: +// return accountViewModel(id: item.id) +// default: +// return nil +// } +// } +//} +// +//private extension AccountListViewModel { +// func accountViewModel(id: String) -> AccountViewModel? { +// guard let account = accounts[id] else { return nil } +// +// var accountViewModel: AccountViewModel +// +// if let cachedViewModel = accountViewModelCache[account]?.0 { +// accountViewModel = cachedViewModel +// } else { +// accountViewModel = AccountViewModel( +// accountService: accountListService.navigationService.accountService(account: account)) +// accountViewModelCache[account] = (accountViewModel, +// accountViewModel.events +// .flatMap { $0 } +// .assignErrorsToAlertItem(to: \.alertItem, on: self) +// .sink { [weak self] in +// guard +// let self = self, +// let event = NavigationEvent($0) +// else { return } +// +// self.navigationEventsSubject.send(event) +// }) +// } +// +// return accountViewModel +// } +// +// func cleanViewModelCache(newAccountSections: [[Account]]) { +// let newAccounts = Set(newAccountSections.reduce([], +)) +// +// accountViewModelCache = accountViewModelCache.filter { newAccounts.contains($0.key) } +// } +//} diff --git a/ViewModels/Sources/ViewModels/AccountViewModel.swift b/ViewModels/Sources/ViewModels/AccountViewModel.swift index 0e4d73a..15e65ad 100644 --- a/ViewModels/Sources/ViewModels/AccountViewModel.swift +++ b/ViewModels/Sources/ViewModels/AccountViewModel.swift @@ -5,7 +5,7 @@ import Foundation import Mastodon import ServiceLayer -public class AccountViewModel: ObservableObject { +public struct AccountViewModel: CollectionItemViewModel { public let events: AnyPublisher, Never> private let accountService: AccountService diff --git a/ViewModels/Sources/ViewModels/CollectionItemViewModel.swift b/ViewModels/Sources/ViewModels/CollectionItemViewModel.swift new file mode 100644 index 0000000..fd709ca --- /dev/null +++ b/ViewModels/Sources/ViewModels/CollectionItemViewModel.swift @@ -0,0 +1,8 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import Combine +import Foundation + +public protocol CollectionItemViewModel { + var events: AnyPublisher, Never> { get } +} diff --git a/ViewModels/Sources/ViewModels/CollectionViewModel.swift b/ViewModels/Sources/ViewModels/CollectionViewModel.swift index 52f6499..79ffd7e 100644 --- a/ViewModels/Sources/ViewModels/CollectionViewModel.swift +++ b/ViewModels/Sources/ViewModels/CollectionViewModel.swift @@ -4,7 +4,7 @@ import Combine import Foundation public protocol CollectionViewModel { - var collectionItems: AnyPublisher<[[CollectionItemIdentifier]], Never> { get } + var sections: AnyPublisher<[[CollectionItemIdentifier]], Never> { get } var title: AnyPublisher { get } var alertItems: AnyPublisher { get } var loading: AnyPublisher { get } @@ -12,7 +12,7 @@ public protocol CollectionViewModel { var nextPageMaxID: String? { get } var maintainScrollPositionOfItem: CollectionItemIdentifier? { get } func request(maxID: String?, minID: String?) - func itemSelected(_ item: CollectionItemIdentifier) - func canSelect(item: CollectionItemIdentifier) -> Bool - func viewModel(item: CollectionItemIdentifier) -> Any? + func select(identifier: CollectionItemIdentifier) + func canSelect(identifier: CollectionItemIdentifier) -> Bool + func viewModel(identifier: CollectionItemIdentifier) -> CollectionItemViewModel? } diff --git a/ViewModels/Sources/ViewModels/Entities/CollectionItemEvent.swift b/ViewModels/Sources/ViewModels/Entities/CollectionItemEvent.swift index da269b2..4577598 100644 --- a/ViewModels/Sources/ViewModels/Entities/CollectionItemEvent.swift +++ b/ViewModels/Sources/ViewModels/Entities/CollectionItemEvent.swift @@ -6,6 +6,5 @@ import ServiceLayer public enum CollectionItemEvent { case ignorableOutput case navigation(Navigation) - case accountListNavigation(AccountListViewModel) case share(URL) } diff --git a/ViewModels/Sources/ViewModels/Entities/CollectionItemIdentifier.swift b/ViewModels/Sources/ViewModels/Entities/CollectionItemIdentifier.swift index d010f45..b8e9a8b 100644 --- a/ViewModels/Sources/ViewModels/Entities/CollectionItemIdentifier.swift +++ b/ViewModels/Sources/ViewModels/Entities/CollectionItemIdentifier.swift @@ -1,6 +1,7 @@ // Copyright © 2020 Metabolist. All rights reserved. import Mastodon +import ServiceLayer public struct CollectionItemIdentifier: Hashable { public let id: String @@ -21,8 +22,8 @@ public extension CollectionItemIdentifier { } extension CollectionItemIdentifier { - init(timelineItem: Timeline.Item) { - switch timelineItem { + init(item: CollectionItem) { + switch item { case let .status(configuration): id = configuration.status.id kind = .status @@ -31,12 +32,10 @@ extension CollectionItemIdentifier { id = loadMore.afterStatusId kind = .loadMore info = [:] + case let .account(account): + id = account.id + kind = .account + info = [:] } } - - init(account: Account) { - id = account.id - kind = .account - info = [:] - } } diff --git a/ViewModels/Sources/ViewModels/Entities/NavigationEvent.swift b/ViewModels/Sources/ViewModels/Entities/NavigationEvent.swift index 191d844..d8e52ee 100644 --- a/ViewModels/Sources/ViewModels/Entities/NavigationEvent.swift +++ b/ViewModels/Sources/ViewModels/Entities/NavigationEvent.swift @@ -20,8 +20,8 @@ extension NavigationEvent { switch item { case let .url(url): self = .urlNavigation(url) - case let .statusList(statusListService): - self = .collectionNavigation(StatusListViewModel(statusListService: statusListService)) + case let .collection(statusListService): + self = .collectionNavigation(ListViewModel(collectionService: statusListService)) case let .profile(profileService): self = .profileNavigation(ProfileViewModel(profileService: profileService)) case .webfingerStart: @@ -29,8 +29,6 @@ extension NavigationEvent { case .webfingerEnd: self = .webfingerEnd } - case let .accountListNavigation(accountListViewModel): - self = .collectionNavigation(accountListViewModel) case let .share(url): self = .share(url) } diff --git a/ViewModels/Sources/ViewModels/StatusListViewModel.swift b/ViewModels/Sources/ViewModels/ListViewModel.swift similarity index 51% rename from ViewModels/Sources/ViewModels/StatusListViewModel.swift rename to ViewModels/Sources/ViewModels/ListViewModel.swift index d026327..6b144f2 100644 --- a/ViewModels/Sources/ViewModels/StatusListViewModel.swift +++ b/ViewModels/Sources/ViewModels/ListViewModel.swift @@ -5,39 +5,39 @@ import Foundation import Mastodon import ServiceLayer -final public class StatusListViewModel: ObservableObject { - @Published public private(set) var items = [[CollectionItemIdentifier]]() +final public class ListViewModel: ObservableObject { + @Published public private(set) var identifiers = [[CollectionItemIdentifier]]() @Published public var alertItem: AlertItem? public private(set) var nextPageMaxID: String? public private(set) var maintainScrollPositionOfItem: CollectionItemIdentifier? - private var timelineItems = [CollectionItemIdentifier: Timeline.Item]() - private let statusListService: StatusListService - private var viewModelCache = [Timeline.Item: (Any, AnyCancellable)]() + private var items = [CollectionItemIdentifier: CollectionItem]() + private let collectionService: CollectionService + private var viewModelCache = [CollectionItem: (CollectionItemViewModel, AnyCancellable)]() private let navigationEventsSubject = PassthroughSubject() private let loadingSubject = PassthroughSubject() private var cancellables = Set() - init(statusListService: StatusListService) { - self.statusListService = statusListService + init(collectionService: CollectionService) { + self.collectionService = collectionService - statusListService.sections + collectionService.sections .handleEvents(receiveOutput: { [weak self] in self?.process(sections: $0) }) - .map { $0.map { $0.map(CollectionItemIdentifier.init(timelineItem:)) } } + .map { $0.map { $0.map(CollectionItemIdentifier.init(item:)) } } .receive(on: DispatchQueue.main) .assignErrorsToAlertItem(to: \.alertItem, on: self) - .assign(to: &$items) + .assign(to: &$identifiers) - statusListService.nextPageMaxIDs + collectionService.nextPageMaxIDs .sink { [weak self] in self?.nextPageMaxID = $0 } .store(in: &cancellables) } } -extension StatusListViewModel: CollectionViewModel { - public var collectionItems: AnyPublisher<[[CollectionItemIdentifier]], Never> { $items.eraseToAnyPublisher() } +extension ListViewModel: CollectionViewModel { + public var sections: AnyPublisher<[[CollectionItemIdentifier]], Never> { $identifiers.eraseToAnyPublisher() } - public var title: AnyPublisher { Just(statusListService.title).eraseToAnyPublisher() } + public var title: AnyPublisher { Just(collectionService.title).eraseToAnyPublisher() } public var alertItems: AnyPublisher { $alertItem.compactMap { $0 }.eraseToAnyPublisher() } @@ -46,7 +46,7 @@ extension StatusListViewModel: CollectionViewModel { public var navigationEvents: AnyPublisher { navigationEventsSubject.eraseToAnyPublisher() } public func request(maxID: String? = nil, minID: String? = nil) { - statusListService.request(maxID: maxID, minID: minID) + collectionService.request(maxID: maxID, minID: minID) .receive(on: DispatchQueue.main) .assignErrorsToAlertItem(to: \.alertItem, on: self) .handleEvents( @@ -56,45 +56,50 @@ extension StatusListViewModel: CollectionViewModel { .store(in: &cancellables) } - public func itemSelected(_ item: CollectionItemIdentifier) { - guard let timelineItem = timelineItems[item] else { return } + public func select(identifier: CollectionItemIdentifier) { + guard let item = items[identifier] else { return } - switch timelineItem { + switch item { case let .status(configuration): navigationEventsSubject.send( .collectionNavigation( - StatusListViewModel( - statusListService: statusListService + ListViewModel( + collectionService: collectionService .navigationService .contextStatusListService(id: configuration.status.displayStatus.id)))) case .loadMore: - loadMoreViewModel(item: item)?.loadMore() + loadMoreViewModel(item: identifier)?.loadMore() + case let .account(account): + navigationEventsSubject.send( + .profileNavigation( + ProfileViewModel( + profileService: collectionService.navigationService.profileService(account: account)))) } } - public func canSelect(item: CollectionItemIdentifier) -> Bool { - if case .status = item.kind, item.id == statusListService.contextParentID { + public func canSelect(identifier: CollectionItemIdentifier) -> Bool { + if case .status = identifier.kind, identifier.id == collectionService.contextParentID { return false } return true } - public func viewModel(item: CollectionItemIdentifier) -> Any? { - switch item.kind { + public func viewModel(identifier: CollectionItemIdentifier) -> CollectionItemViewModel? { + switch identifier.kind { case .status: - return statusViewModel(item: item) + return statusViewModel(item: identifier) case .loadMore: - return loadMoreViewModel(item: item) - default: - return nil + return loadMoreViewModel(item: identifier) + case .account: + return accountViewModel(item: identifier) } } } -private extension StatusListViewModel { +private extension ListViewModel { func statusViewModel(item: CollectionItemIdentifier) -> StatusViewModel? { - guard let timelineItem = timelineItems[item], + guard let timelineItem = items[item], case let .status(configuration) = timelineItem else { return nil } @@ -104,22 +109,11 @@ private extension StatusListViewModel { statusViewModel = cachedViewModel } else { statusViewModel = StatusViewModel( - statusService: statusListService.navigationService.statusService(status: configuration.status)) - viewModelCache[timelineItem] = (statusViewModel, - statusViewModel.events - .flatMap { $0 } - .assignErrorsToAlertItem(to: \.alertItem, on: self) - .sink { [weak self] in - guard - let self = self, - let event = NavigationEvent($0) - else { return } - - self.navigationEventsSubject.send(event) - }) + statusService: collectionService.navigationService.statusService(status: configuration.status)) + cache(viewModel: statusViewModel, forItem: timelineItem) } - statusViewModel.isContextParent = configuration.status.id == statusListService.contextParentID + statusViewModel.isContextParent = configuration.status.id == collectionService.contextParentID statusViewModel.isPinned = configuration.pinned statusViewModel.isReplyInContext = configuration.isReplyInContext statusViewModel.hasReplyFollowing = configuration.hasReplyFollowing @@ -128,7 +122,7 @@ private extension StatusListViewModel { } func loadMoreViewModel(item: CollectionItemIdentifier) -> LoadMoreViewModel? { - guard let timelineItem = timelineItems[item], + guard let timelineItem = items[item], case let .loadMore(loadMore) = timelineItem else { return nil } @@ -137,40 +131,54 @@ private extension StatusListViewModel { } let loadMoreViewModel = LoadMoreViewModel( - loadMoreService: statusListService.navigationService.loadMoreService(loadMore: loadMore)) + loadMoreService: collectionService.navigationService.loadMoreService(loadMore: loadMore)) - viewModelCache[timelineItem] = (loadMoreViewModel, loadMoreViewModel.events - .flatMap { $0 } - .assignErrorsToAlertItem(to: \.alertItem, on: self) - .sink { [weak self] in - guard - let self = self, - let event = NavigationEvent($0) - else { return } - - self.navigationEventsSubject.send(event) - }) + cache(viewModel: loadMoreViewModel, forItem: timelineItem) return loadMoreViewModel } - func process(sections: [[Timeline.Item]]) { + func accountViewModel(item: CollectionItemIdentifier) -> AccountViewModel? { + guard let timelineItem = items[item], + case let .account(account) = timelineItem + else { return nil } + + var accountViewModel: AccountViewModel + + if let cachedViewModel = viewModelCache[timelineItem]?.0 as? AccountViewModel { + accountViewModel = cachedViewModel + } else { + accountViewModel = AccountViewModel( + accountService: collectionService.navigationService.accountService(account: account)) + cache(viewModel: accountViewModel, forItem: timelineItem) + } + + return accountViewModel + } + + func cache(viewModel: CollectionItemViewModel, forItem item: CollectionItem) { + viewModelCache[item] = (viewModel, viewModel.events.flatMap { $0.compactMap(NavigationEvent.init) } + .assignErrorsToAlertItem(to: \.alertItem, on: self) + .sink { [weak self] in self?.navigationEventsSubject.send($0) }) + } + + func process(sections: [[CollectionItem]]) { determineIfScrollPositionShouldBeMaintained(newSections: sections) let timelineItemKeys = Set(sections.reduce([], +)) - timelineItems = Dictionary(uniqueKeysWithValues: timelineItemKeys.map { (.init(timelineItem: $0), $0) }) + items = Dictionary(uniqueKeysWithValues: timelineItemKeys.map { (.init(item: $0), $0) }) viewModelCache = viewModelCache.filter { timelineItemKeys.contains($0.key) } } - func determineIfScrollPositionShouldBeMaintained(newSections: [[Timeline.Item]]) { + func determineIfScrollPositionShouldBeMaintained(newSections: [[CollectionItem]]) { maintainScrollPositionOfItem = nil // clear old value // Maintain scroll position of parent after initial load of context - if let contextParentID = statusListService.contextParentID { + if let contextParentID = collectionService.contextParentID { let contextParentIdentifier = CollectionItemIdentifier(id: contextParentID, kind: .status, info: [:]) - if items == [[], [contextParentIdentifier], []] || items.isEmpty { + if identifiers == [[], [contextParentIdentifier], []] || identifiers.isEmpty { maintainScrollPositionOfItem = contextParentIdentifier } } diff --git a/ViewModels/Sources/ViewModels/LoadMoreViewModel.swift b/ViewModels/Sources/ViewModels/LoadMoreViewModel.swift index 6fac3ad..2beb843 100644 --- a/ViewModels/Sources/ViewModels/LoadMoreViewModel.swift +++ b/ViewModels/Sources/ViewModels/LoadMoreViewModel.swift @@ -3,7 +3,7 @@ import Combine import ServiceLayer -final public class LoadMoreViewModel: ObservableObject { +final public class LoadMoreViewModel: ObservableObject, CollectionItemViewModel { public var direction = LoadMore.Direction.up @Published public private(set) var loading = false public let events: AnyPublisher, Never> diff --git a/ViewModels/Sources/ViewModels/NavigationViewModel.swift b/ViewModels/Sources/ViewModels/NavigationViewModel.swift index ce354d8..f003fff 100644 --- a/ViewModels/Sources/ViewModels/NavigationViewModel.swift +++ b/ViewModels/Sources/ViewModels/NavigationViewModel.swift @@ -90,8 +90,8 @@ public extension NavigationViewModel { .store(in: &cancellables) } - func viewModel(timeline: Timeline) -> StatusListViewModel { - StatusListViewModel(statusListService: identification.service.service(timeline: timeline)) + func viewModel(timeline: Timeline) -> ListViewModel { + ListViewModel(collectionService: identification.service.service(timeline: timeline)) } } diff --git a/ViewModels/Sources/ViewModels/ProfileViewModel.swift b/ViewModels/Sources/ViewModels/ProfileViewModel.swift index 26ef523..29ef1a9 100644 --- a/ViewModels/Sources/ViewModels/ProfileViewModel.swift +++ b/ViewModels/Sources/ViewModels/ProfileViewModel.swift @@ -11,14 +11,14 @@ final public class ProfileViewModel { @Published public var alertItem: AlertItem? private let profileService: ProfileService - private let collectionViewModel: CurrentValueSubject + private let collectionViewModel: CurrentValueSubject private var cancellables = Set() init(profileService: ProfileService) { self.profileService = profileService collectionViewModel = CurrentValueSubject( - StatusListViewModel(statusListService: profileService.statusListService(profileCollection: .statuses))) + ListViewModel(collectionService: profileService.statusListService(profileCollection: .statuses))) profileService.accountServicePublisher .map(AccountViewModel.init(accountService:)) @@ -27,7 +27,7 @@ final public class ProfileViewModel { $collection.dropFirst() .map(profileService.statusListService(profileCollection:)) - .map(StatusListViewModel.init(statusListService:)) + .map(ListViewModel.init(collectionService:)) .sink { [weak self] in guard let self = self else { return } @@ -39,8 +39,8 @@ final public class ProfileViewModel { } extension ProfileViewModel: CollectionViewModel { - public var collectionItems: AnyPublisher<[[CollectionItemIdentifier]], Never> { - collectionViewModel.flatMap(\.collectionItems).eraseToAnyPublisher() + public var sections: AnyPublisher<[[CollectionItemIdentifier]], Never> { + collectionViewModel.flatMap(\.sections).eraseToAnyPublisher() } public var title: AnyPublisher { @@ -85,15 +85,15 @@ extension ProfileViewModel: CollectionViewModel { collectionViewModel.value.request(maxID: maxID, minID: minID) } - public func itemSelected(_ item: CollectionItemIdentifier) { - collectionViewModel.value.itemSelected(item) + public func select(identifier: CollectionItemIdentifier) { + collectionViewModel.value.select(identifier: identifier) } - public func canSelect(item: CollectionItemIdentifier) -> Bool { - collectionViewModel.value.canSelect(item: item) + public func canSelect(identifier: CollectionItemIdentifier) -> Bool { + collectionViewModel.value.canSelect(identifier: identifier) } - public func viewModel(item: CollectionItemIdentifier) -> Any? { - collectionViewModel.value.viewModel(item: item) + public func viewModel(identifier: CollectionItemIdentifier) -> CollectionItemViewModel? { + collectionViewModel.value.viewModel(identifier: identifier) } } diff --git a/ViewModels/Sources/ViewModels/StatusViewModel.swift b/ViewModels/Sources/ViewModels/StatusViewModel.swift index 01e1901..677abd0 100644 --- a/ViewModels/Sources/ViewModels/StatusViewModel.swift +++ b/ViewModels/Sources/ViewModels/StatusViewModel.swift @@ -5,7 +5,7 @@ import Foundation import Mastodon import ServiceLayer -public struct StatusViewModel { +public struct StatusViewModel: CollectionItemViewModel { public let content: NSAttributedString public let contentEmoji: [Emoji] public let displayName: String @@ -127,18 +127,14 @@ public extension StatusViewModel { func rebloggedBySelected() { eventsSubject.send( - Just(CollectionItemEvent.accountListNavigation( - AccountListViewModel( - accountListService: statusService.rebloggedByService()))) + Just(CollectionItemEvent.navigation(.collection(statusService.rebloggedByService()))) .setFailureType(to: Error.self) .eraseToAnyPublisher()) } func favoritedBySelected() { eventsSubject.send( - Just(CollectionItemEvent.accountListNavigation( - AccountListViewModel( - accountListService: statusService.favoritedByService()))) + Just(CollectionItemEvent.navigation(.collection(statusService.favoritedByService()))) .setFailureType(to: Error.self) .eraseToAnyPublisher()) }