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 {
|
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 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue