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() .eraseToAnyPublisher()
} }
func observation(timeline: Timeline) -> AnyPublisher<[[Timeline.Item]], Error> { func observation(timeline: Timeline) -> AnyPublisher<[[CollectionItem]], Error> {
ValueObservation.tracking( ValueObservation.tracking(
TimelineItemsInfo.request(TimelineRecord.filter(TimelineRecord.Columns.id == timeline.id)).fetchOne) TimelineItemsInfo.request(TimelineRecord.filter(TimelineRecord.Columns.id == timeline.id)).fetchOne)
.removeDuplicates() .removeDuplicates()
@ -225,7 +225,7 @@ public extension ContentDatabase {
.eraseToAnyPublisher() .eraseToAnyPublisher()
} }
func contextObservation(parentID: String) -> AnyPublisher<[[Timeline.Item]], Error> { func contextObservation(parentID: String) -> AnyPublisher<[[CollectionItem]], Error> {
ValueObservation.tracking( ValueObservation.tracking(
ContextItemsInfo.request(StatusRecord.filter(StatusRecord.Columns.id == parentID)).fetchOne) ContextItemsInfo.request(StatusRecord.filter(StatusRecord.Columns.id == parentID)).fetchOne)
.removeDuplicates() .removeDuplicates()

View file

@ -21,7 +21,7 @@ extension ContextItemsInfo {
addingIncludes(request).asRequest(of: self) addingIncludes(request).asRequest(of: self)
} }
func items(filters: [Filter]) -> [[Timeline.Item]] { func items(filters: [Filter]) -> [[CollectionItem]] {
let regularExpression = filters.regularExpression(context: .thread) let regularExpression = filters.regularExpression(context: .thread)
return [ancestors, [parent], descendants].map { section in return [ancestors, [parent], descendants].map { section in

View file

@ -28,11 +28,11 @@ extension TimelineItemsInfo {
addingIncludes(request).asRequest(of: self) addingIncludes(request).asRequest(of: self)
} }
func items(filters: [Filter]) -> [[Timeline.Item]] { func items(filters: [Filter]) -> [[CollectionItem]] {
let timeline = Timeline(record: timelineRecord)! let timeline = Timeline(record: timelineRecord)!
let filterRegularExpression = filters.regularExpression(context: timeline.filterContext) let filterRegularExpression = filters.regularExpression(context: timeline.filterContext)
var timelineItems = statusInfos.filtered(regularExpression: filterRegularExpression) 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 { for loadMoreRecord in loadMoreRecords {
guard let index = timelineItems.firstIndex(where: { guard let index = timelineItems.firstIndex(where: {
@ -51,7 +51,7 @@ extension TimelineItemsInfo {
if let pinnedStatusInfos = pinnedStatusesInfo?.pinnedStatusInfos { if let pinnedStatusInfos = pinnedStatusesInfo?.pinnedStatusInfos {
return [pinnedStatusInfos.filtered(regularExpression: filterRegularExpression) 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] timelineItems]
} else { } else {
return [timelineItems] 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 unauthenticatedDefaults: [Timeline] = [.local, .federated]
static let authenticatedDefaults: [Timeline] = [.home, .local, .federated] static let authenticatedDefaults: [Timeline] = [.home, .local, .federated]
enum Item: Hashable {
case status(StatusConfiguration)
case loadMore(LoadMore)
}
var filterContext: Filter.Context { var filterContext: Filter.Context {
switch self { switch self {
case .home, .list: 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 { extension Timeline: Identifiable {
public var id: String { public var id: String {
switch self { 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 Mastodon
import MastodonAPI import MastodonAPI
public struct AccountListService { public struct AccountListService: CollectionService {
public let accountSections: AnyPublisher<[[Account]], Error> public let sections: AnyPublisher<[[CollectionItem]], Error>
public let nextPageMaxIDs: AnyPublisher<String?, Never> public let nextPageMaxIDs: AnyPublisher<String?, Never>
public let navigationService: NavigationService public let navigationService: NavigationService
@ -29,7 +29,9 @@ extension AccountListService {
let nextPageMaxIDsSubject = PassthroughSubject<String?, Never>() let nextPageMaxIDsSubject = PassthroughSubject<String?, Never>()
self.init( self.init(
accountSections: contentDatabase.accountListObservation(list).map { [$0] }.eraseToAnyPublisher(), sections: contentDatabase.accountListObservation(list)
.map { [$0.map { CollectionItem.account($0) }] }
.eraseToAnyPublisher(),
nextPageMaxIDs: nextPageMaxIDsSubject.eraseToAnyPublisher(), nextPageMaxIDs: nextPageMaxIDsSubject.eraseToAnyPublisher(),
navigationService: NavigationService( navigationService: NavigationService(
status: nil, 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 { public enum Navigation {
case url(URL) case url(URL)
case statusList(StatusListService) case collection(CollectionService)
case profile(ProfileService) case profile(ProfileService)
case webfingerStart case webfingerStart
case webfingerEnd case webfingerEnd
@ -30,7 +30,7 @@ public extension NavigationService {
func item(url: URL) -> AnyPublisher<Navigation, Never> { func item(url: URL) -> AnyPublisher<Navigation, Never> {
if let tag = tag(url: url) { if let tag = tag(url: url) {
return Just( return Just(
.statusList( .collection(
StatusListService( StatusListService(
timeline: .tag(tag), timeline: .tag(tag),
mastodonAPIClient: mastodonAPIClient, mastodonAPIClient: mastodonAPIClient,
@ -40,7 +40,7 @@ public extension NavigationService {
return Just(.profile(profileService(id: accountID))).eraseToAnyPublisher() return Just(.profile(profileService(id: accountID))).eraseToAnyPublisher()
} else if mastodonAPIClient.instanceURL.host == url.host, let statusID = url.statusID { } else if mastodonAPIClient.instanceURL.host == url.host, let statusID = url.statusID {
return Just( return Just(
.statusList( .collection(
StatusListService( StatusListService(
statusID: statusID, statusID: statusID,
mastodonAPIClient: mastodonAPIClient, mastodonAPIClient: mastodonAPIClient,
@ -112,7 +112,7 @@ private extension NavigationService {
receiveCompletion: { _ in navigationSubject.send(.webfingerEnd) }) receiveCompletion: { _ in navigationSubject.send(.webfingerEnd) })
.map { results -> Navigation in .map { results -> Navigation in
if let tag = results.hashtags.first { if let tag = results.hashtags.first {
return .statusList( return .collection(
StatusListService( StatusListService(
timeline: .tag(tag.name), timeline: .tag(tag.name),
mastodonAPIClient: mastodonAPIClient, mastodonAPIClient: mastodonAPIClient,
@ -120,7 +120,7 @@ private extension NavigationService {
} else if let account = results.accounts.first { } else if let account = results.accounts.first {
return .profile(profileService(account: account)) return .profile(profileService(account: account))
} else if let status = results.statuses.first { } else if let status = results.statuses.first {
return .statusList( return .collection(
StatusListService( StatusListService(
statusID: status.id, statusID: status.id,
mastodonAPIClient: mastodonAPIClient, mastodonAPIClient: mastodonAPIClient,

View file

@ -6,8 +6,8 @@ import Foundation
import Mastodon import Mastodon
import MastodonAPI import MastodonAPI
public struct StatusListService { public struct StatusListService: CollectionService {
public let sections: AnyPublisher<[[Timeline.Item]], Error> public let sections: AnyPublisher<[[CollectionItem]], Error>
public let nextPageMaxIDs: AnyPublisher<String?, Never> public let nextPageMaxIDs: AnyPublisher<String?, Never>
public let contextParentID: String? public let contextParentID: String?
public let title: String? public let title: String?

View file

@ -15,11 +15,13 @@ class TableViewController: UITableViewController {
DispatchQueue(label: "com.metabolist.metatext.collection.data-source-queue") DispatchQueue(label: "com.metabolist.metatext.collection.data-source-queue")
private lazy var dataSource: UITableViewDiffableDataSource<Int, CollectionItemIdentifier> = { private lazy var dataSource: UITableViewDiffableDataSource<Int, CollectionItemIdentifier> = {
UITableViewDiffableDataSource(tableView: tableView) { [weak self] tableView, indexPath, item in UITableViewDiffableDataSource(tableView: tableView) { [weak self] tableView, indexPath, identifier in
guard let self = self, let cellViewModel = self.viewModel.viewModel(item: item) else { return nil } guard let self = self,
let cellViewModel = self.viewModel.viewModel(identifier: identifier)
else { return nil }
let cell = tableView.dequeueReusableCell( let cell = tableView.dequeueReusableCell(
withIdentifier: String(describing: item.kind.cellClass), withIdentifier: String(describing: identifier.kind.cellClass),
for: indexPath) for: indexPath)
switch (cell, cellViewModel) { switch (cell, cellViewModel) {
@ -111,17 +113,17 @@ class TableViewController: UITableViewController {
} }
override func tableView(_ tableView: UITableView, shouldHighlightRowAt indexPath: IndexPath) -> Bool { 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) { override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: true) 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() { override func viewDidLayoutSubviews() {
@ -185,7 +187,7 @@ private extension TableViewController {
func setupViewModelBindings() { func setupViewModelBindings() {
viewModel.title.sink { [weak self] in self?.navigationItem.title = $0 }.store(in: &cancellables) 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 viewModel.navigationEvents.receive(on: DispatchQueue.main).sink { [weak self] in
guard let self = self else { return } guard let self = self else { return }

View file

@ -1,130 +1,130 @@
// Copyright © 2020 Metabolist. All rights reserved. // Copyright © 2020 Metabolist. All rights reserved.
import Combine //import Combine
import Foundation //import Foundation
import Mastodon //import Mastodon
import ServiceLayer //import ServiceLayer
//
public final class AccountListViewModel: ObservableObject { //public final class AccountListViewModel: ObservableObject {
@Published public private(set) var items = [[CollectionItemIdentifier]]() // @Published public private(set) var items = [[CollectionItemIdentifier]]()
@Published public var alertItem: AlertItem? // @Published public var alertItem: AlertItem?
public let navigationEvents: AnyPublisher<NavigationEvent, Never> // public let navigationEvents: AnyPublisher<NavigationEvent, Never>
public private(set) var nextPageMaxID: String? // public private(set) var nextPageMaxID: String?
//
private let accountListService: AccountListService // private let accountListService: AccountListService
private var accounts = [String: Account]() // private var accounts = [String: Account]()
private var accountViewModelCache = [Account: (AccountViewModel, AnyCancellable)]() // private var accountViewModelCache = [Account: (AccountViewModel, AnyCancellable)]()
private let navigationEventsSubject = PassthroughSubject<NavigationEvent, Never>() // private let navigationEventsSubject = PassthroughSubject<NavigationEvent, Never>()
private let loadingSubject = PassthroughSubject<Bool, Never>() // private let loadingSubject = PassthroughSubject<Bool, Never>()
private var cancellables = Set<AnyCancellable>() // private var cancellables = Set<AnyCancellable>()
//
init(accountListService: AccountListService) { // init(accountListService: AccountListService) {
self.accountListService = accountListService // self.accountListService = accountListService
navigationEvents = navigationEventsSubject.eraseToAnyPublisher() // navigationEvents = navigationEventsSubject.eraseToAnyPublisher()
//
accountListService.accountSections // accountListService.accountSections
.handleEvents(receiveOutput: { [weak self] in // .handleEvents(receiveOutput: { [weak self] in
self?.cleanViewModelCache(newAccountSections: $0) // self?.cleanViewModelCache(newAccountSections: $0)
self?.accounts = Dictionary(uniqueKeysWithValues: Set($0.reduce([], +)).map { ($0.id, $0) }) // self?.accounts = Dictionary(uniqueKeysWithValues: Set($0.reduce([], +)).map { ($0.id, $0) })
}) // })
.map { $0.map { $0.map(CollectionItemIdentifier.init(account:)) } } // .map { $0.map { $0.map(CollectionItemIdentifier.init(account:)) } }
.receive(on: DispatchQueue.main) // .receive(on: DispatchQueue.main)
.assignErrorsToAlertItem(to: \.alertItem, on: self) // .assignErrorsToAlertItem(to: \.alertItem, on: self)
.assign(to: &$items) // .assign(to: &$items)
//
accountListService.nextPageMaxIDs // accountListService.nextPageMaxIDs
.sink { [weak self] in self?.nextPageMaxID = $0 } // .sink { [weak self] in self?.nextPageMaxID = $0 }
.store(in: &cancellables) // .store(in: &cancellables)
} // }
} //}
//
extension AccountListViewModel: CollectionViewModel { //extension AccountListViewModel: CollectionViewModel {
public var collectionItems: AnyPublisher<[[CollectionItemIdentifier]], Never> { $items.eraseToAnyPublisher() } // public var collectionItems: AnyPublisher<[[CollectionItemIdentifier]], Never> { $items.eraseToAnyPublisher() }
//
public var title: AnyPublisher<String?, Never> { Just(nil).eraseToAnyPublisher() } // public var title: AnyPublisher<String?, Never> { Just(nil).eraseToAnyPublisher() }
//
public var alertItems: AnyPublisher<AlertItem, Never> { $alertItem.compactMap { $0 }.eraseToAnyPublisher() } // public var alertItems: AnyPublisher<AlertItem, Never> { $alertItem.compactMap { $0 }.eraseToAnyPublisher() }
//
public var loading: AnyPublisher<Bool, Never> { loadingSubject.eraseToAnyPublisher() } // public var loading: AnyPublisher<Bool, Never> { loadingSubject.eraseToAnyPublisher() }
//
public var maintainScrollPositionOfItem: CollectionItemIdentifier? { // public var maintainScrollPositionOfItem: CollectionItemIdentifier? {
nil // nil
} // }
//
public func request(maxID: String?, minID: String?) { // public func request(maxID: String?, minID: String?) {
accountListService.request(maxID: maxID, minID: minID) // accountListService.request(maxID: maxID, minID: minID)
.receive(on: DispatchQueue.main) // .receive(on: DispatchQueue.main)
.assignErrorsToAlertItem(to: \.alertItem, on: self) // .assignErrorsToAlertItem(to: \.alertItem, on: self)
.handleEvents( // .handleEvents(
receiveSubscription: { [weak self] _ in self?.loadingSubject.send(true) }, // receiveSubscription: { [weak self] _ in self?.loadingSubject.send(true) },
receiveCompletion: { [weak self] _ in self?.loadingSubject.send(false) }) // receiveCompletion: { [weak self] _ in self?.loadingSubject.send(false) })
.sink { _ in } // .sink { _ in }
.store(in: &cancellables) // .store(in: &cancellables)
} // }
//
public func itemSelected(_ item: CollectionItemIdentifier) { // public func itemSelected(_ item: CollectionItemIdentifier) {
switch item.kind { // switch item.kind {
case .account: // case .account:
let navigationService = accountListService.navigationService // let navigationService = accountListService.navigationService
let profileService: ProfileService // let profileService: ProfileService
//
if let account = accounts[item.id] { // if let account = accounts[item.id] {
profileService = navigationService.profileService(account: account) // profileService = navigationService.profileService(account: account)
} else { // } else {
profileService = navigationService.profileService(id: item.id) // profileService = navigationService.profileService(id: item.id)
} // }
//
navigationEventsSubject.send(.profileNavigation(ProfileViewModel(profileService: profileService))) // navigationEventsSubject.send(.profileNavigation(ProfileViewModel(profileService: profileService)))
default: // default:
break // break
} // }
} // }
//
public func canSelect(item: CollectionItemIdentifier) -> Bool { // public func canSelect(item: CollectionItemIdentifier) -> Bool {
true // true
} // }
//
public func viewModel(item: CollectionItemIdentifier) -> Any? { // public func viewModel(item: CollectionItemIdentifier) -> CollectionItemViewModel? {
switch item.kind { // switch item.kind {
case .account: // case .account:
return accountViewModel(id: item.id) // return accountViewModel(id: item.id)
default: // default:
return nil // return nil
} // }
} // }
} //}
//
private extension AccountListViewModel { //private extension AccountListViewModel {
func accountViewModel(id: String) -> AccountViewModel? { // func accountViewModel(id: String) -> AccountViewModel? {
guard let account = accounts[id] else { return nil } // guard let account = accounts[id] else { return nil }
//
var accountViewModel: AccountViewModel // var accountViewModel: AccountViewModel
//
if let cachedViewModel = accountViewModelCache[account]?.0 { // if let cachedViewModel = accountViewModelCache[account]?.0 {
accountViewModel = cachedViewModel // accountViewModel = cachedViewModel
} else { // } else {
accountViewModel = AccountViewModel( // accountViewModel = AccountViewModel(
accountService: accountListService.navigationService.accountService(account: account)) // accountService: accountListService.navigationService.accountService(account: account))
accountViewModelCache[account] = (accountViewModel, // accountViewModelCache[account] = (accountViewModel,
accountViewModel.events // accountViewModel.events
.flatMap { $0 } // .flatMap { $0 }
.assignErrorsToAlertItem(to: \.alertItem, on: self) // .assignErrorsToAlertItem(to: \.alertItem, on: self)
.sink { [weak self] in // .sink { [weak self] in
guard // guard
let self = self, // let self = self,
let event = NavigationEvent($0) // let event = NavigationEvent($0)
else { return } // else { return }
//
self.navigationEventsSubject.send(event) // self.navigationEventsSubject.send(event)
}) // })
} // }
//
return accountViewModel // return accountViewModel
} // }
//
func cleanViewModelCache(newAccountSections: [[Account]]) { // func cleanViewModelCache(newAccountSections: [[Account]]) {
let newAccounts = Set(newAccountSections.reduce([], +)) // let newAccounts = Set(newAccountSections.reduce([], +))
//
accountViewModelCache = accountViewModelCache.filter { newAccounts.contains($0.key) } // accountViewModelCache = accountViewModelCache.filter { newAccounts.contains($0.key) }
} // }
} //}

View file

@ -5,7 +5,7 @@ import Foundation
import Mastodon import Mastodon
import ServiceLayer import ServiceLayer
public class AccountViewModel: ObservableObject { public struct AccountViewModel: CollectionItemViewModel {
public let events: AnyPublisher<AnyPublisher<CollectionItemEvent, Error>, Never> public let events: AnyPublisher<AnyPublisher<CollectionItemEvent, Error>, Never>
private let accountService: AccountService 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 import Foundation
public protocol CollectionViewModel { public protocol CollectionViewModel {
var collectionItems: AnyPublisher<[[CollectionItemIdentifier]], Never> { get } var sections: AnyPublisher<[[CollectionItemIdentifier]], Never> { get }
var title: AnyPublisher<String?, Never> { get } var title: AnyPublisher<String?, Never> { get }
var alertItems: AnyPublisher<AlertItem, Never> { get } var alertItems: AnyPublisher<AlertItem, Never> { get }
var loading: AnyPublisher<Bool, Never> { get } var loading: AnyPublisher<Bool, Never> { get }
@ -12,7 +12,7 @@ public protocol CollectionViewModel {
var nextPageMaxID: String? { get } var nextPageMaxID: String? { get }
var maintainScrollPositionOfItem: CollectionItemIdentifier? { get } var maintainScrollPositionOfItem: CollectionItemIdentifier? { get }
func request(maxID: String?, minID: String?) func request(maxID: String?, minID: String?)
func itemSelected(_ item: CollectionItemIdentifier) func select(identifier: CollectionItemIdentifier)
func canSelect(item: CollectionItemIdentifier) -> Bool func canSelect(identifier: CollectionItemIdentifier) -> Bool
func viewModel(item: CollectionItemIdentifier) -> Any? func viewModel(identifier: CollectionItemIdentifier) -> CollectionItemViewModel?
} }

View file

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

View file

@ -1,6 +1,7 @@
// Copyright © 2020 Metabolist. All rights reserved. // Copyright © 2020 Metabolist. All rights reserved.
import Mastodon import Mastodon
import ServiceLayer
public struct CollectionItemIdentifier: Hashable { public struct CollectionItemIdentifier: Hashable {
public let id: String public let id: String
@ -21,8 +22,8 @@ public extension CollectionItemIdentifier {
} }
extension CollectionItemIdentifier { extension CollectionItemIdentifier {
init(timelineItem: Timeline.Item) { init(item: CollectionItem) {
switch timelineItem { switch item {
case let .status(configuration): case let .status(configuration):
id = configuration.status.id id = configuration.status.id
kind = .status kind = .status
@ -31,12 +32,10 @@ extension CollectionItemIdentifier {
id = loadMore.afterStatusId id = loadMore.afterStatusId
kind = .loadMore kind = .loadMore
info = [:] info = [:]
case let .account(account):
id = account.id
kind = .account
info = [:]
} }
} }
init(account: Account) {
id = account.id
kind = .account
info = [:]
}
} }

View file

@ -20,8 +20,8 @@ extension NavigationEvent {
switch item { switch item {
case let .url(url): case let .url(url):
self = .urlNavigation(url) self = .urlNavigation(url)
case let .statusList(statusListService): case let .collection(statusListService):
self = .collectionNavigation(StatusListViewModel(statusListService: statusListService)) self = .collectionNavigation(ListViewModel(collectionService: statusListService))
case let .profile(profileService): case let .profile(profileService):
self = .profileNavigation(ProfileViewModel(profileService: profileService)) self = .profileNavigation(ProfileViewModel(profileService: profileService))
case .webfingerStart: case .webfingerStart:
@ -29,8 +29,6 @@ extension NavigationEvent {
case .webfingerEnd: case .webfingerEnd:
self = .webfingerEnd self = .webfingerEnd
} }
case let .accountListNavigation(accountListViewModel):
self = .collectionNavigation(accountListViewModel)
case let .share(url): case let .share(url):
self = .share(url) self = .share(url)
} }

View file

@ -5,39 +5,39 @@ import Foundation
import Mastodon import Mastodon
import ServiceLayer import ServiceLayer
final public class StatusListViewModel: ObservableObject { final public class ListViewModel: ObservableObject {
@Published public private(set) var items = [[CollectionItemIdentifier]]() @Published public private(set) var identifiers = [[CollectionItemIdentifier]]()
@Published public var alertItem: AlertItem? @Published public var alertItem: AlertItem?
public private(set) var nextPageMaxID: String? public private(set) var nextPageMaxID: String?
public private(set) var maintainScrollPositionOfItem: CollectionItemIdentifier? public private(set) var maintainScrollPositionOfItem: CollectionItemIdentifier?
private var timelineItems = [CollectionItemIdentifier: Timeline.Item]() private var items = [CollectionItemIdentifier: CollectionItem]()
private let statusListService: StatusListService private let collectionService: CollectionService
private var viewModelCache = [Timeline.Item: (Any, AnyCancellable)]() private var viewModelCache = [CollectionItem: (CollectionItemViewModel, AnyCancellable)]()
private let navigationEventsSubject = PassthroughSubject<NavigationEvent, Never>() private let navigationEventsSubject = PassthroughSubject<NavigationEvent, Never>()
private let loadingSubject = PassthroughSubject<Bool, Never>() private let loadingSubject = PassthroughSubject<Bool, Never>()
private var cancellables = Set<AnyCancellable>() private var cancellables = Set<AnyCancellable>()
init(statusListService: StatusListService) { init(collectionService: CollectionService) {
self.statusListService = statusListService self.collectionService = collectionService
statusListService.sections collectionService.sections
.handleEvents(receiveOutput: { [weak self] in self?.process(sections: $0) }) .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) .receive(on: DispatchQueue.main)
.assignErrorsToAlertItem(to: \.alertItem, on: self) .assignErrorsToAlertItem(to: \.alertItem, on: self)
.assign(to: &$items) .assign(to: &$identifiers)
statusListService.nextPageMaxIDs collectionService.nextPageMaxIDs
.sink { [weak self] in self?.nextPageMaxID = $0 } .sink { [weak self] in self?.nextPageMaxID = $0 }
.store(in: &cancellables) .store(in: &cancellables)
} }
} }
extension StatusListViewModel: CollectionViewModel { extension ListViewModel: CollectionViewModel {
public var collectionItems: AnyPublisher<[[CollectionItemIdentifier]], Never> { $items.eraseToAnyPublisher() } 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() } 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 var navigationEvents: AnyPublisher<NavigationEvent, Never> { navigationEventsSubject.eraseToAnyPublisher() }
public func request(maxID: String? = nil, minID: String? = nil) { 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) .receive(on: DispatchQueue.main)
.assignErrorsToAlertItem(to: \.alertItem, on: self) .assignErrorsToAlertItem(to: \.alertItem, on: self)
.handleEvents( .handleEvents(
@ -56,45 +56,50 @@ extension StatusListViewModel: CollectionViewModel {
.store(in: &cancellables) .store(in: &cancellables)
} }
public func itemSelected(_ item: CollectionItemIdentifier) { public func select(identifier: CollectionItemIdentifier) {
guard let timelineItem = timelineItems[item] else { return } guard let item = items[identifier] else { return }
switch timelineItem { switch item {
case let .status(configuration): case let .status(configuration):
navigationEventsSubject.send( navigationEventsSubject.send(
.collectionNavigation( .collectionNavigation(
StatusListViewModel( ListViewModel(
statusListService: statusListService collectionService: collectionService
.navigationService .navigationService
.contextStatusListService(id: configuration.status.displayStatus.id)))) .contextStatusListService(id: configuration.status.displayStatus.id))))
case .loadMore: 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 { public func canSelect(identifier: CollectionItemIdentifier) -> Bool {
if case .status = item.kind, item.id == statusListService.contextParentID { if case .status = identifier.kind, identifier.id == collectionService.contextParentID {
return false return false
} }
return true return true
} }
public func viewModel(item: CollectionItemIdentifier) -> Any? { public func viewModel(identifier: CollectionItemIdentifier) -> CollectionItemViewModel? {
switch item.kind { switch identifier.kind {
case .status: case .status:
return statusViewModel(item: item) return statusViewModel(item: identifier)
case .loadMore: case .loadMore:
return loadMoreViewModel(item: item) return loadMoreViewModel(item: identifier)
default: case .account:
return nil return accountViewModel(item: identifier)
} }
} }
} }
private extension StatusListViewModel { private extension ListViewModel {
func statusViewModel(item: CollectionItemIdentifier) -> StatusViewModel? { func statusViewModel(item: CollectionItemIdentifier) -> StatusViewModel? {
guard let timelineItem = timelineItems[item], guard let timelineItem = items[item],
case let .status(configuration) = timelineItem case let .status(configuration) = timelineItem
else { return nil } else { return nil }
@ -104,22 +109,11 @@ private extension StatusListViewModel {
statusViewModel = cachedViewModel statusViewModel = cachedViewModel
} else { } else {
statusViewModel = StatusViewModel( statusViewModel = StatusViewModel(
statusService: statusListService.navigationService.statusService(status: configuration.status)) statusService: collectionService.navigationService.statusService(status: configuration.status))
viewModelCache[timelineItem] = (statusViewModel, cache(viewModel: statusViewModel, forItem: timelineItem)
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)
})
} }
statusViewModel.isContextParent = configuration.status.id == statusListService.contextParentID statusViewModel.isContextParent = configuration.status.id == collectionService.contextParentID
statusViewModel.isPinned = configuration.pinned statusViewModel.isPinned = configuration.pinned
statusViewModel.isReplyInContext = configuration.isReplyInContext statusViewModel.isReplyInContext = configuration.isReplyInContext
statusViewModel.hasReplyFollowing = configuration.hasReplyFollowing statusViewModel.hasReplyFollowing = configuration.hasReplyFollowing
@ -128,7 +122,7 @@ private extension StatusListViewModel {
} }
func loadMoreViewModel(item: CollectionItemIdentifier) -> LoadMoreViewModel? { func loadMoreViewModel(item: CollectionItemIdentifier) -> LoadMoreViewModel? {
guard let timelineItem = timelineItems[item], guard let timelineItem = items[item],
case let .loadMore(loadMore) = timelineItem case let .loadMore(loadMore) = timelineItem
else { return nil } else { return nil }
@ -137,40 +131,54 @@ private extension StatusListViewModel {
} }
let loadMoreViewModel = LoadMoreViewModel( let loadMoreViewModel = LoadMoreViewModel(
loadMoreService: statusListService.navigationService.loadMoreService(loadMore: loadMore)) loadMoreService: collectionService.navigationService.loadMoreService(loadMore: loadMore))
viewModelCache[timelineItem] = (loadMoreViewModel, loadMoreViewModel.events cache(viewModel: loadMoreViewModel, forItem: timelineItem)
.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 loadMoreViewModel 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) determineIfScrollPositionShouldBeMaintained(newSections: sections)
let timelineItemKeys = Set(sections.reduce([], +)) 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) } viewModelCache = viewModelCache.filter { timelineItemKeys.contains($0.key) }
} }
func determineIfScrollPositionShouldBeMaintained(newSections: [[Timeline.Item]]) { func determineIfScrollPositionShouldBeMaintained(newSections: [[CollectionItem]]) {
maintainScrollPositionOfItem = nil // clear old value maintainScrollPositionOfItem = nil // clear old value
// Maintain scroll position of parent after initial load of context // 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: [:]) let contextParentIdentifier = CollectionItemIdentifier(id: contextParentID, kind: .status, info: [:])
if items == [[], [contextParentIdentifier], []] || items.isEmpty { if identifiers == [[], [contextParentIdentifier], []] || identifiers.isEmpty {
maintainScrollPositionOfItem = contextParentIdentifier maintainScrollPositionOfItem = contextParentIdentifier
} }
} }

View file

@ -3,7 +3,7 @@
import Combine import Combine
import ServiceLayer import ServiceLayer
final public class LoadMoreViewModel: ObservableObject { final public class LoadMoreViewModel: ObservableObject, CollectionItemViewModel {
public var direction = LoadMore.Direction.up public var direction = LoadMore.Direction.up
@Published public private(set) var loading = false @Published public private(set) var loading = false
public let events: AnyPublisher<AnyPublisher<CollectionItemEvent, Error>, Never> public let events: AnyPublisher<AnyPublisher<CollectionItemEvent, Error>, Never>

View file

@ -90,8 +90,8 @@ public extension NavigationViewModel {
.store(in: &cancellables) .store(in: &cancellables)
} }
func viewModel(timeline: Timeline) -> StatusListViewModel { func viewModel(timeline: Timeline) -> ListViewModel {
StatusListViewModel(statusListService: identification.service.service(timeline: timeline)) ListViewModel(collectionService: identification.service.service(timeline: timeline))
} }
} }

View file

@ -11,14 +11,14 @@ final public class ProfileViewModel {
@Published public var alertItem: AlertItem? @Published public var alertItem: AlertItem?
private let profileService: ProfileService private let profileService: ProfileService
private let collectionViewModel: CurrentValueSubject<StatusListViewModel, Never> private let collectionViewModel: CurrentValueSubject<ListViewModel, Never>
private var cancellables = Set<AnyCancellable>() private var cancellables = Set<AnyCancellable>()
init(profileService: ProfileService) { init(profileService: ProfileService) {
self.profileService = profileService self.profileService = profileService
collectionViewModel = CurrentValueSubject( collectionViewModel = CurrentValueSubject(
StatusListViewModel(statusListService: profileService.statusListService(profileCollection: .statuses))) ListViewModel(collectionService: profileService.statusListService(profileCollection: .statuses)))
profileService.accountServicePublisher profileService.accountServicePublisher
.map(AccountViewModel.init(accountService:)) .map(AccountViewModel.init(accountService:))
@ -27,7 +27,7 @@ final public class ProfileViewModel {
$collection.dropFirst() $collection.dropFirst()
.map(profileService.statusListService(profileCollection:)) .map(profileService.statusListService(profileCollection:))
.map(StatusListViewModel.init(statusListService:)) .map(ListViewModel.init(collectionService:))
.sink { [weak self] in .sink { [weak self] in
guard let self = self else { return } guard let self = self else { return }
@ -39,8 +39,8 @@ final public class ProfileViewModel {
} }
extension ProfileViewModel: CollectionViewModel { extension ProfileViewModel: CollectionViewModel {
public var collectionItems: AnyPublisher<[[CollectionItemIdentifier]], Never> { public var sections: AnyPublisher<[[CollectionItemIdentifier]], Never> {
collectionViewModel.flatMap(\.collectionItems).eraseToAnyPublisher() collectionViewModel.flatMap(\.sections).eraseToAnyPublisher()
} }
public var title: AnyPublisher<String?, Never> { public var title: AnyPublisher<String?, Never> {
@ -85,15 +85,15 @@ extension ProfileViewModel: CollectionViewModel {
collectionViewModel.value.request(maxID: maxID, minID: minID) collectionViewModel.value.request(maxID: maxID, minID: minID)
} }
public func itemSelected(_ item: CollectionItemIdentifier) { public func select(identifier: CollectionItemIdentifier) {
collectionViewModel.value.itemSelected(item) collectionViewModel.value.select(identifier: identifier)
} }
public func canSelect(item: CollectionItemIdentifier) -> Bool { public func canSelect(identifier: CollectionItemIdentifier) -> Bool {
collectionViewModel.value.canSelect(item: item) collectionViewModel.value.canSelect(identifier: identifier)
} }
public func viewModel(item: CollectionItemIdentifier) -> Any? { public func viewModel(identifier: CollectionItemIdentifier) -> CollectionItemViewModel? {
collectionViewModel.value.viewModel(item: item) collectionViewModel.value.viewModel(identifier: identifier)
} }
} }

View file

@ -5,7 +5,7 @@ import Foundation
import Mastodon import Mastodon
import ServiceLayer import ServiceLayer
public struct StatusViewModel { public struct StatusViewModel: CollectionItemViewModel {
public let content: NSAttributedString public let content: NSAttributedString
public let contentEmoji: [Emoji] public let contentEmoji: [Emoji]
public let displayName: String public let displayName: String
@ -127,18 +127,14 @@ public extension StatusViewModel {
func rebloggedBySelected() { func rebloggedBySelected() {
eventsSubject.send( eventsSubject.send(
Just(CollectionItemEvent.accountListNavigation( Just(CollectionItemEvent.navigation(.collection(statusService.rebloggedByService())))
AccountListViewModel(
accountListService: statusService.rebloggedByService())))
.setFailureType(to: Error.self) .setFailureType(to: Error.self)
.eraseToAnyPublisher()) .eraseToAnyPublisher())
} }
func favoritedBySelected() { func favoritedBySelected() {
eventsSubject.send( eventsSubject.send(
Just(CollectionItemEvent.accountListNavigation( Just(CollectionItemEvent.navigation(.collection(statusService.favoritedByService())))
AccountListViewModel(
accountListService: statusService.favoritedByService())))
.setFailureType(to: Error.self) .setFailureType(to: Error.self)
.eraseToAnyPublisher()) .eraseToAnyPublisher())
} }