Account list wip

This commit is contained in:
Justin Mazzocchi 2020-09-24 22:39:06 -07:00
parent 3328306c44
commit a2f84197ef
No known key found for this signature in database
GPG Key ID: E223E6937AAFB01C
12 changed files with 343 additions and 113 deletions

View File

@ -0,0 +1,34 @@
// Copyright © 2020 Metabolist. All rights reserved.
import Foundation
import HTTP
import Mastodon
public enum AccountsEndpoint {
case statusFavouritedBy(id: String)
}
extension AccountsEndpoint: Endpoint {
public typealias ResultType = [Account]
public var context: [String] {
switch self {
case .statusFavouritedBy:
return defaultContext + ["statuses"]
}
}
public var pathComponentsInContext: [String] {
switch self {
case let .statusFavouritedBy(id):
return [id, "favourited_by"]
}
}
public var method: HTTPMethod {
switch self {
case .statusFavouritedBy:
return .get
}
}
}

View File

@ -8,7 +8,8 @@ import MastodonAPI
public struct AccountListService {
public let accountSections: AnyPublisher<[[Account]], Error>
public let paginates: Bool
public let nextPageMaxIDs: AnyPublisher<String?, Never>
public let navigationService: NavigationService
private let mastodonAPIClient: MastodonAPIClient
private let contentDatabase: ContentDatabase
@ -16,7 +17,31 @@ public struct AccountListService {
}
extension AccountListService {
init(favoritedByStatusID statusID: String, mastodonAPIClient: MastodonAPIClient, contentDatabase: ContentDatabase) {
let accountSectionsSubject = PassthroughSubject<[[Account]], Error>()
let nextPageMaxIDsSubject = PassthroughSubject<String?, Never>()
self.init(
accountSections: accountSectionsSubject.eraseToAnyPublisher(),
nextPageMaxIDs: nextPageMaxIDsSubject.eraseToAnyPublisher(),
navigationService: NavigationService(
status: nil,
mastodonAPIClient: mastodonAPIClient,
contentDatabase: contentDatabase),
mastodonAPIClient: mastodonAPIClient,
contentDatabase: contentDatabase) { maxID, minID -> AnyPublisher<Never, Error> in
mastodonAPIClient.pagedRequest(
AccountsEndpoint.statusFavouritedBy(id: statusID), maxID: maxID, minID: minID)
.handleEvents(
receiveOutput: {
nextPageMaxIDsSubject.send($0.info.maxID)
accountSectionsSubject.send([$0.result])
},
receiveCompletion: accountSectionsSubject.send)
.flatMap { contentDatabase.insert(accounts: $0.result) }
.eraseToAnyPublisher()
}
}
}
public extension AccountListService {

View File

@ -8,13 +8,13 @@ import MastodonAPI
public struct AccountService {
public let account: Account
public let urlService: URLService
public let navigationService: NavigationService
private let mastodonAPIClient: MastodonAPIClient
private let contentDatabase: ContentDatabase
init(account: Account, mastodonAPIClient: MastodonAPIClient, contentDatabase: ContentDatabase) {
self.account = account
self.urlService = URLService(
self.navigationService = NavigationService(
status: nil,
mastodonAPIClient: mastodonAPIClient,
contentDatabase: contentDatabase)

View File

@ -6,14 +6,13 @@ import Foundation
import Mastodon
import MastodonAPI
public enum URLItem {
public enum Navigation {
case url(URL)
case statusID(String)
case accountID(String)
case tag(String)
case statusList(StatusListService)
case accountStatuses(AccountStatusesService)
}
public struct URLService {
public struct NavigationService {
private let status: Status?
private let mastodonAPIClient: MastodonAPIClient
private let contentDatabase: ContentDatabase
@ -25,21 +24,49 @@ public struct URLService {
}
}
public extension URLService {
func item(url: URL) -> AnyPublisher<URLItem, Never> {
public extension NavigationService {
func item(url: URL) -> AnyPublisher<Navigation, Never> {
if let tag = tag(url: url) {
return Just(.tag(tag)).eraseToAnyPublisher()
return Just(
.statusList(
StatusListService(
timeline: .tag(tag),
mastodonAPIClient: mastodonAPIClient,
contentDatabase: contentDatabase)))
.eraseToAnyPublisher()
} else if let accountID = accountID(url: url) {
return Just(.accountID(accountID)).eraseToAnyPublisher()
return Just(.accountStatuses(accountStatusesService(id: accountID))).eraseToAnyPublisher()
} else if mastodonAPIClient.instanceURL.host == url.host, let statusID = url.statusID {
return Just(.statusID(statusID)).eraseToAnyPublisher()
return Just(
.statusList(
StatusListService(
statusID: statusID,
mastodonAPIClient: mastodonAPIClient,
contentDatabase: contentDatabase)))
.eraseToAnyPublisher()
}
return Just(.url(url)).eraseToAnyPublisher()
}
func contextStatusListService(id: String) -> StatusListService {
StatusListService(statusID: id, mastodonAPIClient: mastodonAPIClient, contentDatabase: contentDatabase)
}
func accountStatusesService(id: String) -> AccountStatusesService {
AccountStatusesService(id: id, mastodonAPIClient: mastodonAPIClient, contentDatabase: contentDatabase)
}
func statusService(status: Status) -> StatusService {
StatusService(status: status, mastodonAPIClient: mastodonAPIClient, contentDatabase: contentDatabase)
}
func accountService(account: Account) -> AccountService {
AccountService(account: account, mastodonAPIClient: mastodonAPIClient, contentDatabase: contentDatabase)
}
}
private extension URLService {
private extension NavigationService {
func tag(url: URL) -> String? {
if status?.tags.first(where: { $0.url.path.lowercased() == url.path.lowercased() }) != nil {
return url.lastPathComponent

View File

@ -11,6 +11,7 @@ public struct StatusListService {
public let nextPageMaxIDs: AnyPublisher<String?, Never>
public let contextParentID: String?
public let title: String?
public let navigationService: NavigationService
private let filterContext: Filter.Context
private let mastodonAPIClient: MastodonAPIClient
@ -41,6 +42,10 @@ extension StatusListService {
nextPageMaxIDs: nextPageMaxIDsSubject.eraseToAnyPublisher(),
contextParentID: nil,
title: title,
navigationService: NavigationService(
status: nil,
mastodonAPIClient: mastodonAPIClient,
contentDatabase: contentDatabase),
filterContext: filterContext,
mastodonAPIClient: mastodonAPIClient,
contentDatabase: contentDatabase) { maxID, minID in
@ -51,6 +56,29 @@ extension StatusListService {
}
}
init(statusID: String, mastodonAPIClient: MastodonAPIClient, contentDatabase: ContentDatabase) {
self.init(statusSections: contentDatabase.contextObservation(parentID: statusID),
nextPageMaxIDs: Empty().eraseToAnyPublisher(),
contextParentID: statusID,
title: nil,
navigationService: NavigationService(
status: nil,
mastodonAPIClient: mastodonAPIClient,
contentDatabase: contentDatabase),
filterContext: .thread,
mastodonAPIClient: mastodonAPIClient,
contentDatabase: contentDatabase) { _, _ in
Publishers.Merge(
mastodonAPIClient.request(StatusEndpoint.status(id: statusID))
.flatMap(contentDatabase.insert(status:))
.eraseToAnyPublisher(),
mastodonAPIClient.request(ContextEndpoint.context(id: statusID))
.flatMap { contentDatabase.insert(context: $0, parentID: statusID) }
.eraseToAnyPublisher())
.eraseToAnyPublisher()
}
}
init(
accountID: String,
collection: CurrentValueSubject<AccountStatusCollection, Never>,
@ -65,6 +93,10 @@ extension StatusListService {
nextPageMaxIDs: nextPageMaxIDsSubject.eraseToAnyPublisher(),
contextParentID: nil,
title: nil,
navigationService: NavigationService(
status: nil,
mastodonAPIClient: mastodonAPIClient,
contentDatabase: contentDatabase),
filterContext: .account,
mastodonAPIClient: mastodonAPIClient,
contentDatabase: contentDatabase) { maxID, minID in
@ -90,7 +122,12 @@ extension StatusListService {
pinned: false)
return mastodonAPIClient.pagedRequest(endpoint, maxID: maxID, minID: minID)
.handleEvents(receiveOutput: { nextPageMaxIDsSubject.send($0.info.maxID) })
.flatMap { contentDatabase.insert(statuses: $0.result, accountID: accountID, collection: collection.value) }
.flatMap {
contentDatabase.insert(
statuses: $0.result,
accountID: accountID,
collection: collection.value)
}
.eraseToAnyPublisher()
}
}
@ -104,35 +141,4 @@ public extension StatusListService {
var filters: AnyPublisher<[Filter], Error> {
contentDatabase.activeFiltersObservation(date: Date(), context: filterContext)
}
func statusService(status: Status) -> StatusService {
StatusService(status: status, mastodonAPIClient: mastodonAPIClient, contentDatabase: contentDatabase)
}
func service(timeline: Timeline) -> Self {
Self(timeline: timeline, mastodonAPIClient: mastodonAPIClient, contentDatabase: contentDatabase)
}
func service(accountID: String) -> AccountStatusesService {
AccountStatusesService(id: accountID, mastodonAPIClient: mastodonAPIClient, contentDatabase: contentDatabase)
}
func contextService(statusID: String) -> Self {
Self(statusSections: contentDatabase.contextObservation(parentID: statusID),
nextPageMaxIDs: Empty().eraseToAnyPublisher(),
contextParentID: statusID,
title: nil,
filterContext: .thread,
mastodonAPIClient: mastodonAPIClient,
contentDatabase: contentDatabase) { _, _ in
Publishers.Merge(
mastodonAPIClient.request(StatusEndpoint.status(id: statusID))
.flatMap(contentDatabase.insert(status:))
.eraseToAnyPublisher(),
mastodonAPIClient.request(ContextEndpoint.context(id: statusID))
.flatMap { contentDatabase.insert(context: $0, parentID: statusID) }
.eraseToAnyPublisher())
.eraseToAnyPublisher()
}
}
}

View File

@ -8,13 +8,13 @@ import MastodonAPI
public struct StatusService {
public let status: Status
public let urlService: URLService
public let navigationService: NavigationService
private let mastodonAPIClient: MastodonAPIClient
private let contentDatabase: ContentDatabase
init(status: Status, mastodonAPIClient: MastodonAPIClient, contentDatabase: ContentDatabase) {
self.status = status
self.urlService = URLService(
self.navigationService = NavigationService(
status: status.displayStatus,
mastodonAPIClient: mastodonAPIClient,
contentDatabase: contentDatabase)
@ -31,4 +31,11 @@ public extension StatusService {
.flatMap(contentDatabase.insert(status:))
.eraseToAnyPublisher()
}
func favoritedByService() -> AccountListService {
AccountListService(
favoritedByStatusID: status.id,
mastodonAPIClient: mastodonAPIClient,
contentDatabase: contentDatabase)
}
}

View File

@ -1,7 +1,126 @@
// Copyright © 2020 Metabolist. All rights reserved.
import Combine
import Foundation
import Mastodon
import ServiceLayer
public class AccountListViewModel: ObservableObject {
public final class AccountListViewModel: ObservableObject {
@Published public private(set) var items = [[CollectionItem]]()
@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 { CollectionItem(id: $0.id, kind: .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<[[CollectionItem]], 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: CollectionItem? {
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: CollectionItem) {
switch item.kind {
case .account:
navigationEventsSubject.send(
.collectionNavigation(
AccountStatusesViewModel(
accountStatusesService: accountListService
.navigationService
.accountStatusesService(id: item.id))))
default:
break
}
}
public func canSelect(item: CollectionItem) -> Bool {
true
}
public func viewModel(item: CollectionItem) -> 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) }
}
}

View File

@ -6,10 +6,14 @@ import Mastodon
import ServiceLayer
public class AccountViewModel: ObservableObject {
public let events: AnyPublisher<AnyPublisher<CollectionItemEvent, Error>, Never>
private let accountService: AccountService
private let eventsSubject = PassthroughSubject<AnyPublisher<CollectionItemEvent, Error>, Never>()
init(accountService: AccountService) {
self.accountService = accountService
events = eventsSubject.eraseToAnyPublisher()
}
}

View File

@ -0,0 +1,11 @@
// Copyright © 2020 Metabolist. All rights reserved.
import Foundation
import ServiceLayer
public enum CollectionItemEvent {
case ignorableOutput
case navigation(Navigation)
case accountListNavigation(AccountListViewModel)
case share(URL)
}

View File

@ -7,3 +7,25 @@ public enum NavigationEvent {
case urlNavigation(URL)
case share(URL)
}
extension NavigationEvent {
init?(_ event: CollectionItemEvent) {
switch event {
case .ignorableOutput:
return nil
case let .navigation(item):
switch item {
case let .url(url):
self = .urlNavigation(url)
case let .statusList(statusListService):
self = .collectionNavigation(StatusListViewModel(statusListService: statusListService))
case let .accountStatuses(accountStatusesService):
self = .collectionNavigation(AccountStatusesViewModel(accountStatusesService: accountStatusesService))
}
case let .accountListNavigation(accountListViewModel):
self = .collectionNavigation(accountListViewModel)
case let .share(url):
self = .share(url)
}
}
}

View File

@ -64,9 +64,7 @@ extension StatusListViewModel: CollectionViewModel {
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 func itemSelected(_ item: CollectionItem) {
switch item.kind {
@ -76,7 +74,9 @@ extension StatusListViewModel: CollectionViewModel {
navigationEventsSubject.send(
.collectionNavigation(
StatusListViewModel(
statusListService: statusListService.contextService(statusID: displayStatusID))))
statusListService: statusListService
.navigationService
.contextStatusListService(id: displayStatusID))))
default:
break
}
@ -100,7 +100,15 @@ extension StatusListViewModel: CollectionViewModel {
}
}
public extension StatusListViewModel {
private extension StatusListViewModel {
static func filter(statusSections: [[Status]], regularExpression: String?) -> [[Status]] {
guard let regEx = regularExpression else { return statusSections }
return statusSections.map {
$0.filter { $0.filterableContent.range(of: regEx, options: [.regularExpression, .caseInsensitive]) == nil }
}
}
var contextParentID: String? { statusListService.contextParentID }
func statusViewModel(id: String) -> StatusViewModel? {
@ -111,15 +119,18 @@ public extension StatusListViewModel {
if let cachedViewModel = statusViewModelCache[status]?.0 {
statusViewModel = cachedViewModel
} else {
statusViewModel = StatusViewModel(statusService: statusListService.statusService(status: status))
statusViewModel = StatusViewModel(
statusService: statusListService.navigationService.statusService(status: status))
statusViewModelCache[status] = (statusViewModel,
statusViewModel.events
.flatMap { $0 }
.assignErrorsToAlertItem(to: \.alertItem, on: self)
.sink { [weak self] in
guard let self = self,
let event = self.navigationEvent(statusEvent: $0)
guard
let self = self,
let event = NavigationEvent($0)
else { return }
self.navigationEventsSubject.send(event)
})
}
@ -131,44 +142,6 @@ public extension StatusListViewModel {
return statusViewModel
}
}
private extension StatusListViewModel {
static func filter(statusSections: [[Status]], regularExpression: String?) -> [[Status]] {
guard let regEx = regularExpression else { return statusSections }
return statusSections.map {
$0.filter { $0.filterableContent.range(of: regEx, options: [.regularExpression, .caseInsensitive]) == nil }
}
}
func navigationEvent(statusEvent: StatusViewModel.Event) -> NavigationEvent? {
switch statusEvent {
case .ignorableOutput:
return nil
case let .navigation(item):
switch item {
case let .url(url):
return .urlNavigation(url)
case let .accountID(id):
return .collectionNavigation(
AccountStatusesViewModel(accountStatusesService: statusListService.service(accountID: id)))
case let .statusID(id):
return .collectionNavigation(
StatusListViewModel(
statusListService: statusListService.contextService(statusID: id)))
case let .tag(tag):
return .collectionNavigation(
StatusListViewModel(
statusListService: statusListService.service(timeline: Timeline.tag(tag))))
}
case let .accountListNavigation(accountListViewModel):
// return .collectionNavigation(accountListViewModel)
return nil
case let .share(url):
return .share(url)
}
}
func determineIfScrollPositionShouldBeMaintained(newStatusSections: [[Status]]) {
maintainScrollPositionOfItem = nil // clear old value

View File

@ -22,10 +22,10 @@ public struct StatusViewModel {
public var isReplyInContext = false
public var hasReplyFollowing = false
public var sensitiveContentToggled = false
public let events: AnyPublisher<AnyPublisher<Event, Error>, Never>
public let events: AnyPublisher<AnyPublisher<CollectionItemEvent, Error>, Never>
private let statusService: StatusService
private let eventsSubject = PassthroughSubject<AnyPublisher<Event, Error>, Never>()
private let eventsSubject = PassthroughSubject<AnyPublisher<CollectionItemEvent, Error>, Never>()
init(statusService: StatusService) {
self.statusService = statusService
@ -49,15 +49,6 @@ public struct StatusViewModel {
}
}
public extension StatusViewModel {
enum Event {
case ignorableOutput
case navigation(URLItem)
case accountListNavigation(AccountListViewModel)
case share(URL)
}
}
public extension StatusViewModel {
var shouldDisplaySensitiveContent: Bool {
if statusService.status.displayStatus.sensitive {
@ -118,31 +109,42 @@ public extension StatusViewModel {
func urlSelected(_ url: URL) {
eventsSubject.send(
statusService.urlService.item(url: url)
.map { Event.navigation($0) }
statusService.navigationService.item(url: url)
.map { CollectionItemEvent.navigation($0) }
.setFailureType(to: Error.self)
.eraseToAnyPublisher())
}
func accountSelected() {
eventsSubject.send(
Just(Event.navigation(.accountID(statusService.status.displayStatus.account.id)))
Just(CollectionItemEvent.navigation(
.accountStatuses(
statusService.navigationService.accountStatusesService(
id: statusService.status.displayStatus.account.id))))
.setFailureType(to: Error.self)
.eraseToAnyPublisher())
}
func favoritedBySelected() {
eventsSubject.send(
Just(CollectionItemEvent.accountListNavigation(
AccountListViewModel(
accountListService: statusService.favoritedByService())))
.setFailureType(to: Error.self)
.eraseToAnyPublisher())
}
func toggleFavorited() {
eventsSubject.send(statusService.toggleFavorited().map { _ in Event.ignorableOutput }.eraseToAnyPublisher())
eventsSubject.send(
statusService.toggleFavorited()
.map { _ in CollectionItemEvent.ignorableOutput }
.eraseToAnyPublisher())
}
func shareStatus() {
guard let url = statusService.status.displayStatus.url else { return }
eventsSubject.send(Just(Event.share(url)).setFailureType(to: Error.self).eraseToAnyPublisher())
eventsSubject.send(Just(CollectionItemEvent.share(url)).setFailureType(to: Error.self).eraseToAnyPublisher())
}
}