mirror of
https://github.com/metabolist/metatext.git
synced 2024-11-25 09:41:00 +00:00
Collections refactor WIP
This commit is contained in:
parent
90d750464b
commit
f3e1baecaa
23 changed files with 313 additions and 275 deletions
|
@ -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()
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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]
|
||||
|
|
25
DB/Sources/DB/Entities/CollectionItem.swift
Normal file
25
DB/Sources/DB/Entities/CollectionItem.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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 {
|
||||
|
|
|
@ -0,0 +1,5 @@
|
|||
// Copyright © 2020 Metabolist. All rights reserved.
|
||||
|
||||
import DB
|
||||
|
||||
public typealias CollectionItem = DB.CollectionItem
|
|
@ -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,
|
||||
|
|
|
@ -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 }
|
||||
}
|
|
@ -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,
|
||||
|
|
|
@ -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?
|
||||
|
|
|
@ -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 }
|
||||
|
|
|
@ -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) }
|
||||
// }
|
||||
//}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 }
|
||||
}
|
|
@ -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?
|
||||
}
|
||||
|
|
|
@ -6,6 +6,5 @@ import ServiceLayer
|
|||
public enum CollectionItemEvent {
|
||||
case ignorableOutput
|
||||
case navigation(Navigation)
|
||||
case accountListNavigation(AccountListViewModel)
|
||||
case share(URL)
|
||||
}
|
||||
|
|
|
@ -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 = [:]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
|
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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())
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue