From cb8a36023de83534983fa364409b397287ff4bd7 Mon Sep 17 00:00:00 2001 From: Justin Mazzocchi <2831158+jzzocc@users.noreply.github.com> Date: Wed, 2 Sep 2020 02:07:09 -0700 Subject: [PATCH] Refactor status lists --- .../Extensions/URL+Extensions.swift | 42 ++++++++++ .../ServiceLayer/IdentityService.swift | 2 +- .../Status List Services/ContextService.swift | 84 ------------------- .../StatusListService.swift | 30 ------- .../TimelineService.swift | 53 ------------ .../ServiceLayer/StatusListService.swift | 75 +++++++++++++++++ .../StatusListViewController.swift | 27 +++--- .../ViewModels/StatusListViewModel.swift | 39 +++++++-- 8 files changed, 163 insertions(+), 189 deletions(-) create mode 100644 ServiceLayer/Sources/ServiceLayer/Extensions/URL+Extensions.swift delete mode 100644 ServiceLayer/Sources/ServiceLayer/Status List Services/ContextService.swift delete mode 100644 ServiceLayer/Sources/ServiceLayer/Status List Services/StatusListService.swift delete mode 100644 ServiceLayer/Sources/ServiceLayer/Status List Services/TimelineService.swift create mode 100644 ServiceLayer/Sources/ServiceLayer/StatusListService.swift diff --git a/ServiceLayer/Sources/ServiceLayer/Extensions/URL+Extensions.swift b/ServiceLayer/Sources/ServiceLayer/Extensions/URL+Extensions.swift new file mode 100644 index 0000000..d7d4954 --- /dev/null +++ b/ServiceLayer/Sources/ServiceLayer/Extensions/URL+Extensions.swift @@ -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 + } +} diff --git a/ServiceLayer/Sources/ServiceLayer/IdentityService.swift b/ServiceLayer/Sources/ServiceLayer/IdentityService.swift index db22626..862ff27 100644 --- a/ServiceLayer/Sources/ServiceLayer/IdentityService.swift +++ b/ServiceLayer/Sources/ServiceLayer/IdentityService.swift @@ -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) } } diff --git a/ServiceLayer/Sources/ServiceLayer/Status List Services/ContextService.swift b/ServiceLayer/Sources/ServiceLayer/Status List Services/ContextService.swift deleted file mode 100644 index 9c691b0..0000000 --- a/ServiceLayer/Sources/ServiceLayer/Status List Services/ContextService.swift +++ /dev/null @@ -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(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 { - 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 - } -} diff --git a/ServiceLayer/Sources/ServiceLayer/Status List Services/StatusListService.swift b/ServiceLayer/Sources/ServiceLayer/Status List Services/StatusListService.swift deleted file mode 100644 index 2d63ac3..0000000 --- a/ServiceLayer/Sources/ServiceLayer/Status List Services/StatusListService.swift +++ /dev/null @@ -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 - 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 } -} diff --git a/ServiceLayer/Sources/ServiceLayer/Status List Services/TimelineService.swift b/ServiceLayer/Sources/ServiceLayer/Status List Services/TimelineService.swift deleted file mode 100644 index dd24497..0000000 --- a/ServiceLayer/Sources/ServiceLayer/Status List Services/TimelineService.swift +++ /dev/null @@ -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 { - 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 - } - } -} diff --git a/ServiceLayer/Sources/ServiceLayer/StatusListService.swift b/ServiceLayer/Sources/ServiceLayer/StatusListService.swift new file mode 100644 index 0000000..ec53d78 --- /dev/null +++ b/ServiceLayer/Sources/ServiceLayer/StatusListService.swift @@ -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 +} + +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 { + 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() + } + } +} diff --git a/View Controllers/StatusListViewController.swift b/View Controllers/StatusListViewController.swift index 10e2215..397e2e0 100644 --- a/View Controllers/StatusListViewController.swift +++ b/View Controllers/StatusListViewController.swift @@ -9,6 +9,8 @@ class StatusListViewController: UITableViewController { private let loadingTableFooterView = LoadingTableFooterView() private var cancellables = Set() private var cellHeightCaches = [CGFloat: [String: CGFloat]]() + private let dataSourceQueue = + DispatchQueue(label: "com.metabolist.metatext.status-list.data-source-queue") private lazy var dataSource: UITableViewDiffableDataSource = { 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) } diff --git a/ViewModels/Sources/ViewModels/StatusListViewModel.swift b/ViewModels/Sources/ViewModels/StatusListViewModel.swift index bcb845c..3b18841 100644 --- a/ViewModels/Sources/ViewModels/StatusListViewModel.swift +++ b/ViewModels/Sources/ViewModels/StatusListViewModel.swift @@ -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 + } }