Refactor status lists
This commit is contained in:
parent
a47667c15b
commit
cb8a36023d
|
@ -0,0 +1,42 @@
|
|||
// Copyright © 2020 Metabolist. All rights reserved.
|
||||
|
||||
import Foundation
|
||||
|
||||
extension URL {
|
||||
var isAccountURL: Bool {
|
||||
(pathComponents.count == 2 && pathComponents[1].starts(with: "@"))
|
||||
|| (pathComponents.count == 3 && pathComponents[0...1] == ["/", "users"])
|
||||
}
|
||||
|
||||
var accountID: String? {
|
||||
if let accountID = pathComponents.last, pathComponents == ["/", "web", "accounts", accountID] {
|
||||
return accountID
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
var statusID: String? {
|
||||
guard let statusID = pathComponents.last else { return nil }
|
||||
|
||||
if pathComponents.count == 3, pathComponents[1].starts(with: "@") {
|
||||
return statusID
|
||||
} else if pathComponents == ["/", "web", "statuses", statusID] {
|
||||
return statusID
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
var tag: String? {
|
||||
if let tag = pathComponents.last, pathComponents == ["/", "tags", tag] {
|
||||
return tag
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
var shouldWebfinger: Bool {
|
||||
isAccountURL || accountID != nil || statusID != nil || tag != nil
|
||||
}
|
||||
}
|
|
@ -199,7 +199,7 @@ public extension IdentityService {
|
|||
}
|
||||
|
||||
func service(timeline: Timeline) -> StatusListService {
|
||||
TimelineService(timeline: timeline, networkClient: networkClient, contentDatabase: contentDatabase)
|
||||
StatusListService(timeline: timeline, networkClient: networkClient, contentDatabase: contentDatabase)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,84 +0,0 @@
|
|||
// Copyright © 2020 Metabolist. All rights reserved.
|
||||
|
||||
import Foundation
|
||||
import Combine
|
||||
import Mastodon
|
||||
|
||||
public struct ContextService {
|
||||
public let statusSections: AnyPublisher<[[Status]], Error>
|
||||
public let paginates = false
|
||||
|
||||
private let status: Status
|
||||
private let context = CurrentValueSubject<Context, Never>(Context(ancestors: [], descendants: []))
|
||||
private let networkClient: APIClient
|
||||
private let contentDatabase: ContentDatabase
|
||||
|
||||
init(status: Status, networkClient: APIClient, contentDatabase: ContentDatabase) {
|
||||
self.status = status
|
||||
self.networkClient = networkClient
|
||||
self.contentDatabase = contentDatabase
|
||||
statusSections = contentDatabase.contextObservation(parentID: status.id)
|
||||
}
|
||||
}
|
||||
|
||||
extension ContextService: StatusListService {
|
||||
public var filters: AnyPublisher<[Filter], Error> {
|
||||
contentDatabase.activeFiltersObservation(date: Date(), context: .thread)
|
||||
}
|
||||
|
||||
public var contextParentID: String? { status.id }
|
||||
|
||||
public func isReplyInContext(status: Status) -> Bool {
|
||||
let flatContext = flattenedContext()
|
||||
|
||||
guard
|
||||
let index = flatContext.firstIndex(where: { $0.id == status.id }),
|
||||
index > 0
|
||||
else { return false }
|
||||
|
||||
let previousStatus = flatContext[index - 1]
|
||||
|
||||
return previousStatus.id != contextParentID && status.inReplyToId == previousStatus.id
|
||||
}
|
||||
|
||||
public func hasReplyFollowing(status: Status) -> Bool {
|
||||
let flatContext = flattenedContext()
|
||||
|
||||
guard
|
||||
let index = flatContext.firstIndex(where: { $0.id == status.id }),
|
||||
flatContext.count > index + 1
|
||||
else { return false }
|
||||
|
||||
let nextStatus = flatContext[index + 1]
|
||||
|
||||
return status.id != contextParentID && nextStatus.inReplyToId == status.id
|
||||
}
|
||||
|
||||
public func request(maxID: String?, minID: String?) -> AnyPublisher<Never, Error> {
|
||||
Publishers.Merge(
|
||||
networkClient.request(StatusEndpoint.status(id: status.id))
|
||||
.map { ([$0], nil) }
|
||||
.flatMap(contentDatabase.insert(statuses:timeline:))
|
||||
.eraseToAnyPublisher(),
|
||||
networkClient.request(ContextEndpoint.context(id: status.id))
|
||||
.handleEvents(receiveOutput: context.send)
|
||||
.map { ($0, status.id) }
|
||||
.flatMap(contentDatabase.insert(context:parentID:))
|
||||
.eraseToAnyPublisher())
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
public func statusService(status: Status) -> StatusService {
|
||||
StatusService(status: status, networkClient: networkClient, contentDatabase: contentDatabase)
|
||||
}
|
||||
|
||||
public func contextService(status: Status) -> ContextService {
|
||||
ContextService(status: status.displayStatus, networkClient: networkClient, contentDatabase: contentDatabase)
|
||||
}
|
||||
}
|
||||
|
||||
private extension ContextService {
|
||||
func flattenedContext() -> [Status] {
|
||||
context.value.ancestors + [status] + context.value.descendants
|
||||
}
|
||||
}
|
|
@ -1,30 +0,0 @@
|
|||
// Copyright © 2020 Metabolist. All rights reserved.
|
||||
|
||||
import Foundation
|
||||
import Combine
|
||||
import Mastodon
|
||||
|
||||
public protocol StatusListService {
|
||||
var statusSections: AnyPublisher<[[Status]], Error> { get }
|
||||
var filters: AnyPublisher<[Filter], Error> { get }
|
||||
var paginates: Bool { get }
|
||||
var contextParentID: String? { get }
|
||||
func isPinned(status: Status) -> Bool
|
||||
func isReplyInContext(status: Status) -> Bool
|
||||
func hasReplyFollowing(status: Status) -> Bool
|
||||
func request(maxID: String?, minID: String?) -> AnyPublisher<Never, Error>
|
||||
func statusService(status: Status) -> StatusService
|
||||
func contextService(status: Status) -> ContextService
|
||||
}
|
||||
|
||||
public extension StatusListService {
|
||||
var paginates: Bool { true }
|
||||
|
||||
var contextParentID: String? { nil }
|
||||
|
||||
func isPinned(status: Status) -> Bool { false }
|
||||
|
||||
func isReplyInContext(status: Status) -> Bool { false }
|
||||
|
||||
func hasReplyFollowing(status: Status) -> Bool { false }
|
||||
}
|
|
@ -1,53 +0,0 @@
|
|||
// Copyright © 2020 Metabolist. All rights reserved.
|
||||
|
||||
import Foundation
|
||||
import Combine
|
||||
import Mastodon
|
||||
|
||||
struct TimelineService {
|
||||
let statusSections: AnyPublisher<[[Status]], Error>
|
||||
|
||||
private let timeline: Timeline
|
||||
private let networkClient: APIClient
|
||||
private let contentDatabase: ContentDatabase
|
||||
|
||||
init(timeline: Timeline, networkClient: APIClient, contentDatabase: ContentDatabase) {
|
||||
self.timeline = timeline
|
||||
self.networkClient = networkClient
|
||||
self.contentDatabase = contentDatabase
|
||||
statusSections = contentDatabase.statusesObservation(timeline: timeline)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
}
|
||||
|
||||
extension TimelineService: StatusListService {
|
||||
var filters: AnyPublisher<[Filter], Error> {
|
||||
contentDatabase.activeFiltersObservation(date: Date(), context: filterContext)
|
||||
}
|
||||
|
||||
func request(maxID: String?, minID: String?) -> AnyPublisher<Never, Error> {
|
||||
networkClient.request(Paged(timeline.endpoint, maxID: maxID, minID: minID))
|
||||
.map { ($0, timeline) }
|
||||
.flatMap(contentDatabase.insert(statuses:timeline:))
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
func statusService(status: Status) -> StatusService {
|
||||
StatusService(status: status, networkClient: networkClient, contentDatabase: contentDatabase)
|
||||
}
|
||||
|
||||
func contextService(status: Status) -> ContextService {
|
||||
ContextService(status: status.displayStatus, networkClient: networkClient, contentDatabase: contentDatabase)
|
||||
}
|
||||
}
|
||||
|
||||
private extension TimelineService {
|
||||
var filterContext: Filter.Context {
|
||||
switch timeline {
|
||||
case .home, .list:
|
||||
return .home
|
||||
case .local, .federated, .tag:
|
||||
return .public
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,75 @@
|
|||
// Copyright © 2020 Metabolist. All rights reserved.
|
||||
|
||||
import Combine
|
||||
import Foundation
|
||||
import Mastodon
|
||||
|
||||
public struct StatusListService {
|
||||
public let statusSections: AnyPublisher<[[Status]], Error>
|
||||
public let paginates: Bool
|
||||
public let contextParentID: String?
|
||||
|
||||
private let filterContext: Filter.Context
|
||||
private let networkClient: APIClient
|
||||
private let contentDatabase: ContentDatabase
|
||||
private let requestClosure: (_ maxID: String?, _ minID: String?) -> AnyPublisher<Never, Error>
|
||||
}
|
||||
|
||||
extension StatusListService {
|
||||
init(timeline: Timeline, networkClient: APIClient, contentDatabase: ContentDatabase) {
|
||||
let filterContext: Filter.Context
|
||||
|
||||
switch timeline {
|
||||
case .home, .list:
|
||||
filterContext = .home
|
||||
case .local, .federated, .tag:
|
||||
filterContext = .public
|
||||
}
|
||||
|
||||
self.init(statusSections: contentDatabase.statusesObservation(timeline: timeline),
|
||||
paginates: true,
|
||||
contextParentID: nil,
|
||||
filterContext: filterContext,
|
||||
networkClient: networkClient,
|
||||
contentDatabase: contentDatabase) { maxID, minID in
|
||||
networkClient.request(Paged(timeline.endpoint, maxID: maxID, minID: minID))
|
||||
.map { ($0, timeline) }
|
||||
.flatMap(contentDatabase.insert(statuses:timeline:))
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public extension StatusListService {
|
||||
func request(maxID: String?, minID: String?) -> AnyPublisher<Never, Error> {
|
||||
requestClosure(maxID, minID)
|
||||
}
|
||||
|
||||
var filters: AnyPublisher<[Filter], Error> {
|
||||
contentDatabase.activeFiltersObservation(date: Date(), context: filterContext)
|
||||
}
|
||||
|
||||
func statusService(status: Status) -> StatusService {
|
||||
StatusService(status: status, networkClient: networkClient, contentDatabase: contentDatabase)
|
||||
}
|
||||
|
||||
func contextService(statusID: String) -> Self {
|
||||
Self(statusSections: contentDatabase.contextObservation(parentID: statusID),
|
||||
paginates: false,
|
||||
contextParentID: statusID,
|
||||
filterContext: .thread,
|
||||
networkClient: networkClient,
|
||||
contentDatabase: contentDatabase) { _, _ in
|
||||
Publishers.Merge(
|
||||
networkClient.request(StatusEndpoint.status(id: statusID))
|
||||
.map { ([$0], nil) }
|
||||
.flatMap(contentDatabase.insert(statuses:timeline:))
|
||||
.eraseToAnyPublisher(),
|
||||
networkClient.request(ContextEndpoint.context(id: statusID))
|
||||
.map { ($0, statusID) }
|
||||
.flatMap(contentDatabase.insert(context:parentID:))
|
||||
.eraseToAnyPublisher())
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
}
|
||||
}
|
|
@ -9,6 +9,8 @@ class StatusListViewController: UITableViewController {
|
|||
private let loadingTableFooterView = LoadingTableFooterView()
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
private var cellHeightCaches = [CGFloat: [String: CGFloat]]()
|
||||
private let dataSourceQueue =
|
||||
DispatchQueue(label: "com.metabolist.metatext.status-list.data-source-queue")
|
||||
|
||||
private lazy var dataSource: UITableViewDiffableDataSource<Int, String> = {
|
||||
UITableViewDiffableDataSource(tableView: tableView) { [weak self] tableView, indexPath, statusID in
|
||||
|
@ -54,7 +56,7 @@ class StatusListViewController: UITableViewController {
|
|||
tableView.tableFooterView = UIView()
|
||||
|
||||
viewModel.$statusIDs
|
||||
.sink { [weak self] in
|
||||
.sink { [weak self] statusIDs in
|
||||
guard let self = self else { return }
|
||||
|
||||
var offsetFromNavigationBar: CGFloat?
|
||||
|
@ -67,14 +69,16 @@ class StatusListViewController: UITableViewController {
|
|||
offsetFromNavigationBar = self.tableView.rectForRow(at: indexPath).origin.y - navigationBarMaxY
|
||||
}
|
||||
|
||||
self.dataSource.apply($0.snapshot(), animatingDifferences: false) {
|
||||
if
|
||||
let id = self.viewModel.maintainScrollPositionOfStatusID,
|
||||
let indexPath = self.dataSource.indexPath(for: id) {
|
||||
self.tableView.scrollToRow(at: indexPath, at: .top, animated: false)
|
||||
self.dataSourceQueue.async {
|
||||
self.dataSource.apply(statusIDs.snapshot(), animatingDifferences: false) {
|
||||
if
|
||||
let id = self.viewModel.maintainScrollPositionOfStatusID,
|
||||
let indexPath = self.dataSource.indexPath(for: id) {
|
||||
self.tableView.scrollToRow(at: indexPath, at: .top, animated: false)
|
||||
|
||||
if let offsetFromNavigationBar = offsetFromNavigationBar {
|
||||
self.tableView.contentOffset.y -= offsetFromNavigationBar
|
||||
if let offsetFromNavigationBar = offsetFromNavigationBar {
|
||||
self.tableView.contentOffset.y -= offsetFromNavigationBar
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -122,13 +126,10 @@ class StatusListViewController: UITableViewController {
|
|||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
guard
|
||||
let id = dataSource.itemIdentifier(for: indexPath),
|
||||
let contextViewModel = viewModel.contextViewModel(id: id)
|
||||
else { return }
|
||||
guard let id = dataSource.itemIdentifier(for: indexPath) else { return }
|
||||
|
||||
navigationController?.pushViewController(
|
||||
StatusListViewController(viewModel: contextViewModel),
|
||||
StatusListViewController(viewModel: viewModel.contextViewModel(id: id)),
|
||||
animated: true)
|
||||
}
|
||||
|
||||
|
|
|
@ -65,18 +65,16 @@ public extension StatusListViewModel {
|
|||
.sink { _ in })
|
||||
}
|
||||
|
||||
statusViewModel.isContextParent = status.id == contextParentID
|
||||
statusViewModel.isPinned = statusListService.isPinned(status: status)
|
||||
statusViewModel.isReplyInContext = statusListService.isReplyInContext(status: status)
|
||||
statusViewModel.hasReplyFollowing = statusListService.hasReplyFollowing(status: status)
|
||||
statusViewModel.isContextParent = status.id == statusListService.contextParentID
|
||||
statusViewModel.isPinned = status.displayStatus.pinned ?? false
|
||||
statusViewModel.isReplyInContext = isReplyInContext(status: status)
|
||||
statusViewModel.hasReplyFollowing = hasReplyFollowing(status: status)
|
||||
|
||||
return statusViewModel
|
||||
}
|
||||
|
||||
func contextViewModel(id: String) -> StatusListViewModel? {
|
||||
guard let status = statuses[id] else { return nil }
|
||||
|
||||
return StatusListViewModel(statusListService: statusListService.contextService(status: status))
|
||||
func contextViewModel(id: String) -> StatusListViewModel {
|
||||
StatusListViewModel(statusListService: statusListService.contextService(statusID: id))
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -105,4 +103,29 @@ private extension StatusListViewModel {
|
|||
|
||||
statusViewModelCache = statusViewModelCache.filter { newStatuses.contains($0.key) }
|
||||
}
|
||||
|
||||
func isReplyInContext(status: Status) -> Bool {
|
||||
let flatStatusIDs = statusIDs.reduce([], +)
|
||||
|
||||
guard
|
||||
let index = flatStatusIDs.firstIndex(where: { $0 == status.id }),
|
||||
index > 0
|
||||
else { return false }
|
||||
|
||||
let previousStatusID = flatStatusIDs[index - 1]
|
||||
|
||||
return previousStatusID != contextParentID && status.inReplyToId == previousStatusID
|
||||
}
|
||||
|
||||
func hasReplyFollowing(status: Status) -> Bool {
|
||||
let flatStatusIDs = statusIDs.reduce([], +)
|
||||
|
||||
guard
|
||||
let index = flatStatusIDs.firstIndex(where: { $0 == status.id }),
|
||||
flatStatusIDs.count > index + 1,
|
||||
let nextStatus = statuses[flatStatusIDs[index + 1]]
|
||||
else { return false }
|
||||
|
||||
return status.id != contextParentID && nextStatus.inReplyToId == status.id
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue