Refactor status lists

This commit is contained in:
Justin Mazzocchi 2020-09-02 02:07:09 -07:00
parent a47667c15b
commit cb8a36023d
No known key found for this signature in database
GPG Key ID: E223E6937AAFB01C
8 changed files with 163 additions and 189 deletions

View File

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

View File

@ -199,7 +199,7 @@ public extension IdentityService {
} }
func service(timeline: Timeline) -> StatusListService { func service(timeline: Timeline) -> StatusListService {
TimelineService(timeline: timeline, networkClient: networkClient, contentDatabase: contentDatabase) StatusListService(timeline: timeline, networkClient: networkClient, contentDatabase: contentDatabase)
} }
} }

View File

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

View File

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

View File

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

View File

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

View File

@ -9,6 +9,8 @@ class StatusListViewController: UITableViewController {
private let loadingTableFooterView = LoadingTableFooterView() private let loadingTableFooterView = LoadingTableFooterView()
private var cancellables = Set<AnyCancellable>() private var cancellables = Set<AnyCancellable>()
private var cellHeightCaches = [CGFloat: [String: CGFloat]]() 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> = { private lazy var dataSource: UITableViewDiffableDataSource<Int, String> = {
UITableViewDiffableDataSource(tableView: tableView) { [weak self] tableView, indexPath, statusID in UITableViewDiffableDataSource(tableView: tableView) { [weak self] tableView, indexPath, statusID in
@ -54,7 +56,7 @@ class StatusListViewController: UITableViewController {
tableView.tableFooterView = UIView() tableView.tableFooterView = UIView()
viewModel.$statusIDs viewModel.$statusIDs
.sink { [weak self] in .sink { [weak self] statusIDs in
guard let self = self else { return } guard let self = self else { return }
var offsetFromNavigationBar: CGFloat? var offsetFromNavigationBar: CGFloat?
@ -67,14 +69,16 @@ class StatusListViewController: UITableViewController {
offsetFromNavigationBar = self.tableView.rectForRow(at: indexPath).origin.y - navigationBarMaxY offsetFromNavigationBar = self.tableView.rectForRow(at: indexPath).origin.y - navigationBarMaxY
} }
self.dataSource.apply($0.snapshot(), animatingDifferences: false) { self.dataSourceQueue.async {
if self.dataSource.apply(statusIDs.snapshot(), animatingDifferences: false) {
let id = self.viewModel.maintainScrollPositionOfStatusID, if
let indexPath = self.dataSource.indexPath(for: id) { let id = self.viewModel.maintainScrollPositionOfStatusID,
self.tableView.scrollToRow(at: indexPath, at: .top, animated: false) let indexPath = self.dataSource.indexPath(for: id) {
self.tableView.scrollToRow(at: indexPath, at: .top, animated: false)
if let offsetFromNavigationBar = offsetFromNavigationBar { if let offsetFromNavigationBar = offsetFromNavigationBar {
self.tableView.contentOffset.y -= offsetFromNavigationBar self.tableView.contentOffset.y -= offsetFromNavigationBar
}
} }
} }
} }
@ -122,13 +126,10 @@ class StatusListViewController: UITableViewController {
} }
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
guard guard let id = dataSource.itemIdentifier(for: indexPath) else { return }
let id = dataSource.itemIdentifier(for: indexPath),
let contextViewModel = viewModel.contextViewModel(id: id)
else { return }
navigationController?.pushViewController( navigationController?.pushViewController(
StatusListViewController(viewModel: contextViewModel), StatusListViewController(viewModel: viewModel.contextViewModel(id: id)),
animated: true) animated: true)
} }

View File

@ -65,18 +65,16 @@ public extension StatusListViewModel {
.sink { _ in }) .sink { _ in })
} }
statusViewModel.isContextParent = status.id == contextParentID statusViewModel.isContextParent = status.id == statusListService.contextParentID
statusViewModel.isPinned = statusListService.isPinned(status: status) statusViewModel.isPinned = status.displayStatus.pinned ?? false
statusViewModel.isReplyInContext = statusListService.isReplyInContext(status: status) statusViewModel.isReplyInContext = isReplyInContext(status: status)
statusViewModel.hasReplyFollowing = statusListService.hasReplyFollowing(status: status) statusViewModel.hasReplyFollowing = hasReplyFollowing(status: status)
return statusViewModel return statusViewModel
} }
func contextViewModel(id: String) -> StatusListViewModel? { func contextViewModel(id: String) -> StatusListViewModel {
guard let status = statuses[id] else { return nil } StatusListViewModel(statusListService: statusListService.contextService(statusID: id))
return StatusListViewModel(statusListService: statusListService.contextService(status: status))
} }
} }
@ -105,4 +103,29 @@ private extension StatusListViewModel {
statusViewModelCache = statusViewModelCache.filter { newStatuses.contains($0.key) } 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
}
} }