Collections refactor WIP

This commit is contained in:
Justin Mazzocchi 2020-10-04 23:36:22 -07:00
parent 90d750464b
commit f3e1baecaa
No known key found for this signature in database
GPG key ID: E223E6937AAFB01C
23 changed files with 313 additions and 275 deletions

View file

@ -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()

View file

@ -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

View file

@ -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]

View file

@ -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
}
}
}

View file

@ -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 {

View file

@ -0,0 +1,5 @@
// Copyright © 2020 Metabolist. All rights reserved.
import DB
public typealias CollectionItem = DB.CollectionItem

View file

@ -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<String?, Never>
public let navigationService: NavigationService
@ -29,7 +29,9 @@ extension AccountListService {
let nextPageMaxIDsSubject = PassthroughSubject<String?, Never>()
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,

View file

@ -0,0 +1,17 @@
// Copyright © 2020 Metabolist. All rights reserved.
import Combine
public protocol CollectionService {
var sections: AnyPublisher<[[CollectionItem]], Error> { get }
var nextPageMaxIDs: AnyPublisher<String?, Never> { get }
var navigationService: NavigationService { get }
var title: String? { get }
var contextParentID: String? { get }
func request(maxID: String?, minID: String?) -> AnyPublisher<Never, Error>
}
extension CollectionService {
public var title: String? { nil }
public var contextParentID: String? { nil }
}

View file

@ -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<Navigation, Never> {
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,

View file

@ -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<String?, Never>
public let contextParentID: String?
public let title: String?

View file

@ -15,11 +15,13 @@ class TableViewController: UITableViewController {
DispatchQueue(label: "com.metabolist.metatext.collection.data-source-queue")
private lazy var dataSource: UITableViewDiffableDataSource<Int, CollectionItemIdentifier> = {
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 }

View file

@ -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<NavigationEvent, Never>
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<NavigationEvent, Never>()
private let loadingSubject = PassthroughSubject<Bool, Never>()
private var cancellables = Set<AnyCancellable>()
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<String?, Never> { Just(nil).eraseToAnyPublisher() }
public var alertItems: AnyPublisher<AlertItem, Never> { $alertItem.compactMap { $0 }.eraseToAnyPublisher() }
public var loading: AnyPublisher<Bool, Never> { 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<NavigationEvent, Never>
// 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<NavigationEvent, Never>()
// private let loadingSubject = PassthroughSubject<Bool, Never>()
// private var cancellables = Set<AnyCancellable>()
//
// 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<String?, Never> { Just(nil).eraseToAnyPublisher() }
//
// public var alertItems: AnyPublisher<AlertItem, Never> { $alertItem.compactMap { $0 }.eraseToAnyPublisher() }
//
// public var loading: AnyPublisher<Bool, Never> { 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) }
// }
//}

View file

@ -5,7 +5,7 @@ import Foundation
import Mastodon
import ServiceLayer
public class AccountViewModel: ObservableObject {
public struct AccountViewModel: CollectionItemViewModel {
public let events: AnyPublisher<AnyPublisher<CollectionItemEvent, Error>, Never>
private let accountService: AccountService

View file

@ -0,0 +1,8 @@
// Copyright © 2020 Metabolist. All rights reserved.
import Combine
import Foundation
public protocol CollectionItemViewModel {
var events: AnyPublisher<AnyPublisher<CollectionItemEvent, Error>, Never> { get }
}

View file

@ -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<String?, Never> { get }
var alertItems: AnyPublisher<AlertItem, Never> { get }
var loading: AnyPublisher<Bool, Never> { 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?
}

View file

@ -6,6 +6,5 @@ import ServiceLayer
public enum CollectionItemEvent {
case ignorableOutput
case navigation(Navigation)
case accountListNavigation(AccountListViewModel)
case share(URL)
}

View file

@ -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 = [:]
}
}
init(account: Account) {
case let .account(account):
id = account.id
kind = .account
info = [:]
}
}
}

View file

@ -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)
}

View file

@ -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<NavigationEvent, Never>()
private let loadingSubject = PassthroughSubject<Bool, Never>()
private var cancellables = Set<AnyCancellable>()
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<String?, Never> { Just(statusListService.title).eraseToAnyPublisher() }
public var title: AnyPublisher<String?, Never> { Just(collectionService.title).eraseToAnyPublisher() }
public var alertItems: AnyPublisher<AlertItem, Never> { $alertItem.compactMap { $0 }.eraseToAnyPublisher() }
@ -46,7 +46,7 @@ extension StatusListViewModel: CollectionViewModel {
public var navigationEvents: AnyPublisher<NavigationEvent, Never> { 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
}
}

View file

@ -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<AnyPublisher<CollectionItemEvent, Error>, Never>

View file

@ -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))
}
}

View file

@ -11,14 +11,14 @@ final public class ProfileViewModel {
@Published public var alertItem: AlertItem?
private let profileService: ProfileService
private let collectionViewModel: CurrentValueSubject<StatusListViewModel, Never>
private let collectionViewModel: CurrentValueSubject<ListViewModel, Never>
private var cancellables = Set<AnyCancellable>()
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<String?, Never> {
@ -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)
}
}

View file

@ -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())
}