Refactoring
This commit is contained in:
parent
55ba5f856a
commit
b13f4b89a8
|
@ -0,0 +1,14 @@
|
|||
// Copyright © 2020 Metabolist. All rights reserved.
|
||||
|
||||
import ViewModels
|
||||
|
||||
extension CollectionItem.Kind {
|
||||
var cellClass: AnyClass {
|
||||
switch self {
|
||||
case .status:
|
||||
return StatusListCell.self
|
||||
case .account:
|
||||
return AccountListCell.self
|
||||
}
|
||||
}
|
||||
}
|
|
@ -30,14 +30,13 @@
|
|||
D0C7D49724F7616A001EBDBB /* IdentitiesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0C7D42224F76169001EBDBB /* IdentitiesView.swift */; };
|
||||
D0C7D49824F7616A001EBDBB /* CustomEmojiText.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0C7D42324F76169001EBDBB /* CustomEmojiText.swift */; };
|
||||
D0C7D49924F7616A001EBDBB /* AddIdentityView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0C7D42424F76169001EBDBB /* AddIdentityView.swift */; };
|
||||
D0C7D49A24F7616A001EBDBB /* StatusListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0C7D42524F76169001EBDBB /* StatusListView.swift */; };
|
||||
D0C7D49A24F7616A001EBDBB /* CollectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0C7D42524F76169001EBDBB /* CollectionView.swift */; };
|
||||
D0C7D49B24F7616A001EBDBB /* PreferencesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0C7D42624F76169001EBDBB /* PreferencesView.swift */; };
|
||||
D0C7D49C24F7616A001EBDBB /* RootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0C7D42724F76169001EBDBB /* RootView.swift */; };
|
||||
D0C7D49D24F7616A001EBDBB /* PostingReadingPreferencesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0C7D42824F76169001EBDBB /* PostingReadingPreferencesView.swift */; };
|
||||
D0C7D49E24F7616A001EBDBB /* SecondaryNavigationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0C7D42924F76169001EBDBB /* SecondaryNavigationView.swift */; };
|
||||
D0C7D4A224F7616A001EBDBB /* NotificationTypesPreferencesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0C7D42D24F76169001EBDBB /* NotificationTypesPreferencesView.swift */; };
|
||||
D0C7D4A324F7616A001EBDBB /* TabNavigationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0C7D42E24F76169001EBDBB /* TabNavigationView.swift */; };
|
||||
D0C7D4A524F7616A001EBDBB /* StatusListViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0C7D43124F76169001EBDBB /* StatusListViewController.swift */; };
|
||||
D0C7D4C224F7616A001EBDBB /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = D0C7D45224F76169001EBDBB /* Assets.xcassets */; };
|
||||
D0C7D4C324F7616A001EBDBB /* MetatextApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0C7D45424F76169001EBDBB /* MetatextApp.swift */; };
|
||||
D0C7D4C424F7616A001EBDBB /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0C7D45524F76169001EBDBB /* AppDelegate.swift */; };
|
||||
|
@ -51,6 +50,11 @@
|
|||
D0E2C1D124FD97F000854680 /* ViewModels in Frameworks */ = {isa = PBXBuildFile; productRef = D0E2C1D024FD97F000854680 /* ViewModels */; };
|
||||
D0E5361C24E3EB4D00FB1CE1 /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0E5361B24E3EB4D00FB1CE1 /* NotificationService.swift */; };
|
||||
D0E5362024E3EB4D00FB1CE1 /* Notification Service Extension.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = D0E5361924E3EB4D00FB1CE1 /* Notification Service Extension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
D0F0B10E251A868200942152 /* AccountView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0F0B10D251A868200942152 /* AccountView.swift */; };
|
||||
D0F0B113251A86A000942152 /* AccountContentConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0F0B112251A86A000942152 /* AccountContentConfiguration.swift */; };
|
||||
D0F0B126251A90F400942152 /* AccountListCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0F0B125251A90F400942152 /* AccountListCell.swift */; };
|
||||
D0F0B12E251A97E400942152 /* CollectionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0F0B12D251A97E400942152 /* CollectionViewController.swift */; };
|
||||
D0F0B136251AA12700942152 /* CollectionItemKind+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0F0B135251AA12700942152 /* CollectionItemKind+Extensions.swift */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
|
@ -116,14 +120,13 @@
|
|||
D0C7D42224F76169001EBDBB /* IdentitiesView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = IdentitiesView.swift; sourceTree = "<group>"; };
|
||||
D0C7D42324F76169001EBDBB /* CustomEmojiText.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CustomEmojiText.swift; sourceTree = "<group>"; };
|
||||
D0C7D42424F76169001EBDBB /* AddIdentityView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AddIdentityView.swift; sourceTree = "<group>"; };
|
||||
D0C7D42524F76169001EBDBB /* StatusListView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StatusListView.swift; sourceTree = "<group>"; };
|
||||
D0C7D42524F76169001EBDBB /* CollectionView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CollectionView.swift; sourceTree = "<group>"; };
|
||||
D0C7D42624F76169001EBDBB /* PreferencesView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PreferencesView.swift; sourceTree = "<group>"; };
|
||||
D0C7D42724F76169001EBDBB /* RootView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RootView.swift; sourceTree = "<group>"; };
|
||||
D0C7D42824F76169001EBDBB /* PostingReadingPreferencesView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PostingReadingPreferencesView.swift; sourceTree = "<group>"; };
|
||||
D0C7D42924F76169001EBDBB /* SecondaryNavigationView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SecondaryNavigationView.swift; sourceTree = "<group>"; };
|
||||
D0C7D42D24F76169001EBDBB /* NotificationTypesPreferencesView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NotificationTypesPreferencesView.swift; sourceTree = "<group>"; };
|
||||
D0C7D42E24F76169001EBDBB /* TabNavigationView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TabNavigationView.swift; sourceTree = "<group>"; };
|
||||
D0C7D43124F76169001EBDBB /* StatusListViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StatusListViewController.swift; sourceTree = "<group>"; };
|
||||
D0C7D45224F76169001EBDBB /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||
D0C7D45424F76169001EBDBB /* MetatextApp.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MetatextApp.swift; sourceTree = "<group>"; };
|
||||
D0C7D45524F76169001EBDBB /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
||||
|
@ -141,6 +144,11 @@
|
|||
D0E5361B24E3EB4D00FB1CE1 /* NotificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = "<group>"; };
|
||||
D0E5361D24E3EB4D00FB1CE1 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
D0E5362824E4A06B00FB1CE1 /* Notification Service Extension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "Notification Service Extension.entitlements"; sourceTree = "<group>"; };
|
||||
D0F0B10D251A868200942152 /* AccountView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountView.swift; sourceTree = "<group>"; };
|
||||
D0F0B112251A86A000942152 /* AccountContentConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountContentConfiguration.swift; sourceTree = "<group>"; };
|
||||
D0F0B125251A90F400942152 /* AccountListCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountListCell.swift; sourceTree = "<group>"; };
|
||||
D0F0B12D251A97E400942152 /* CollectionViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollectionViewController.swift; sourceTree = "<group>"; };
|
||||
D0F0B135251AA12700942152 /* CollectionItemKind+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CollectionItemKind+Extensions.swift"; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
|
@ -257,9 +265,13 @@
|
|||
D0C7D42024F76169001EBDBB /* Views */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
D0F0B112251A86A000942152 /* AccountContentConfiguration.swift */,
|
||||
D01EF22325182B1F00650C6B /* AccountHeaderView.swift */,
|
||||
D0F0B125251A90F400942152 /* AccountListCell.swift */,
|
||||
D0F0B10D251A868200942152 /* AccountView.swift */,
|
||||
D0C7D42424F76169001EBDBB /* AddIdentityView.swift */,
|
||||
D01F41E024F8885900D55A2D /* Attachments */,
|
||||
D0C7D42524F76169001EBDBB /* CollectionView.swift */,
|
||||
D0C7D42324F76169001EBDBB /* CustomEmojiText.swift */,
|
||||
D0BEB21024FA2A90001B0F04 /* EditFilterView.swift */,
|
||||
D0BEB20424FA1107001B0F04 /* FiltersView.swift */,
|
||||
|
@ -274,7 +286,6 @@
|
|||
D02E1F94250B13210071AD56 /* SafariView.swift */,
|
||||
D0C7D42924F76169001EBDBB /* SecondaryNavigationView.swift */,
|
||||
D0625E55250F086B00502611 /* Status */,
|
||||
D0C7D42524F76169001EBDBB /* StatusListView.swift */,
|
||||
D0C7D42E24F76169001EBDBB /* TabNavigationView.swift */,
|
||||
D01F41D624F880C400D55A2D /* TouchFallthroughTextView.swift */,
|
||||
);
|
||||
|
@ -284,7 +295,7 @@
|
|||
D0C7D43024F76169001EBDBB /* View Controllers */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
D0C7D43124F76169001EBDBB /* StatusListViewController.swift */,
|
||||
D0F0B12D251A97E400942152 /* CollectionViewController.swift */,
|
||||
);
|
||||
path = "View Controllers";
|
||||
sourceTree = "<group>";
|
||||
|
@ -311,6 +322,7 @@
|
|||
isa = PBXGroup;
|
||||
children = (
|
||||
D0B5FE9A251583DB00478838 /* AccountStatusCollection+Extensions.swift */,
|
||||
D0F0B135251AA12700942152 /* CollectionItemKind+Extensions.swift */,
|
||||
D0C7D46E24F76169001EBDBB /* KingfisherOptionsInfo+Extensions.swift */,
|
||||
D0C7D46B24F76169001EBDBB /* NSMutableAttributedString+Extensions.swift */,
|
||||
D0C7D46A24F76169001EBDBB /* String+Extensions.swift */,
|
||||
|
@ -502,12 +514,15 @@
|
|||
D0C7D4A324F7616A001EBDBB /* TabNavigationView.swift in Sources */,
|
||||
D02E1F95250B13210071AD56 /* SafariView.swift in Sources */,
|
||||
D0C7D49C24F7616A001EBDBB /* RootView.swift in Sources */,
|
||||
D0F0B126251A90F400942152 /* AccountListCell.swift in Sources */,
|
||||
D0B32F50250B373600311912 /* RegistrationView.swift in Sources */,
|
||||
D0F0B136251AA12700942152 /* CollectionItemKind+Extensions.swift in Sources */,
|
||||
D0625E5D250F0B5C00502611 /* StatusContentConfiguration.swift in Sources */,
|
||||
D0BEB1F324F8EE8C001B0F04 /* AttachmentView.swift in Sources */,
|
||||
D0C7D49A24F7616A001EBDBB /* StatusListView.swift in Sources */,
|
||||
D0C7D49A24F7616A001EBDBB /* CollectionView.swift in Sources */,
|
||||
D0F0B12E251A97E400942152 /* CollectionViewController.swift in Sources */,
|
||||
D0F0B113251A86A000942152 /* AccountContentConfiguration.swift in Sources */,
|
||||
D01F41D924F880C400D55A2D /* TouchFallthroughTextView.swift in Sources */,
|
||||
D0C7D4A524F7616A001EBDBB /* StatusListViewController.swift in Sources */,
|
||||
D0C7D4D624F7616A001EBDBB /* NSMutableAttributedString+Extensions.swift in Sources */,
|
||||
D0625E5F250F0CFF00502611 /* StatusView.swift in Sources */,
|
||||
D0625E59250F092900502611 /* StatusListCell.swift in Sources */,
|
||||
|
@ -519,6 +534,7 @@
|
|||
D0C7D4A224F7616A001EBDBB /* NotificationTypesPreferencesView.swift in Sources */,
|
||||
D0BEB1F724F9A84B001B0F04 /* LoadingTableFooterView.swift in Sources */,
|
||||
D0C7D4D924F7616A001EBDBB /* KingfisherOptionsInfo+Extensions.swift in Sources */,
|
||||
D0F0B10E251A868200942152 /* AccountView.swift in Sources */,
|
||||
D0BEB1FF24F9E5BB001B0F04 /* ListsView.swift in Sources */,
|
||||
D0C7D49724F7616A001EBDBB /* IdentitiesView.swift in Sources */,
|
||||
D01EF22425182B1F00650C6B /* AccountHeaderView.swift in Sources */,
|
||||
|
|
|
@ -0,0 +1,26 @@
|
|||
// Copyright © 2020 Metabolist. All rights reserved.
|
||||
|
||||
import Combine
|
||||
import DB
|
||||
import Foundation
|
||||
import Mastodon
|
||||
import MastodonAPI
|
||||
|
||||
public struct AccountListService {
|
||||
public let accountSections: AnyPublisher<[[Account]], Error>
|
||||
public let paginates: Bool
|
||||
|
||||
private let mastodonAPIClient: MastodonAPIClient
|
||||
private let contentDatabase: ContentDatabase
|
||||
private let requestClosure: (_ maxID: String?, _ minID: String?) -> AnyPublisher<Never, Error>
|
||||
}
|
||||
|
||||
extension AccountListService {
|
||||
|
||||
}
|
||||
|
||||
public extension AccountListService {
|
||||
func request(maxID: String?, minID: String?) -> AnyPublisher<Never, Error> {
|
||||
requestClosure(maxID, minID)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,24 @@
|
|||
// Copyright © 2020 Metabolist. All rights reserved.
|
||||
|
||||
import Combine
|
||||
import DB
|
||||
import Foundation
|
||||
import Mastodon
|
||||
import MastodonAPI
|
||||
|
||||
public struct AccountService {
|
||||
public let account: Account
|
||||
public let urlService: URLService
|
||||
private let mastodonAPIClient: MastodonAPIClient
|
||||
private let contentDatabase: ContentDatabase
|
||||
|
||||
init(account: Account, mastodonAPIClient: MastodonAPIClient, contentDatabase: ContentDatabase) {
|
||||
self.account = account
|
||||
self.urlService = URLService(
|
||||
status: nil,
|
||||
mastodonAPIClient: mastodonAPIClient,
|
||||
contentDatabase: contentDatabase)
|
||||
self.mastodonAPIClient = mastodonAPIClient
|
||||
self.contentDatabase = contentDatabase
|
||||
}
|
||||
}
|
|
@ -5,30 +5,36 @@ import SafariServices
|
|||
import SwiftUI
|
||||
import ViewModels
|
||||
|
||||
class StatusListViewController: UITableViewController {
|
||||
private let viewModel: StatusListViewModel
|
||||
class CollectionViewController: UITableViewController {
|
||||
private let viewModel: CollectionViewModel
|
||||
private let loadingTableFooterView = LoadingTableFooterView()
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
private var cellHeightCaches = [CGFloat: [String: CGFloat]]()
|
||||
private var cellHeightCaches = [CGFloat: [CollectionItem: CGFloat]]()
|
||||
private let dataSourceQueue =
|
||||
DispatchQueue(label: "com.metabolist.metatext.status-list.data-source-queue")
|
||||
DispatchQueue(label: "com.metabolist.metatext.collection.data-source-queue")
|
||||
|
||||
private lazy var dataSource: UITableViewDiffableDataSource<Int, CollectionItem> = {
|
||||
UITableViewDiffableDataSource(tableView: tableView) { [weak self] tableView, indexPath, item in
|
||||
guard let self = self, let cellViewModel = self.viewModel.viewModel(item: item) else { return nil }
|
||||
|
||||
private lazy var dataSource: UITableViewDiffableDataSource<Int, String> = {
|
||||
UITableViewDiffableDataSource(tableView: tableView) { [weak self] tableView, indexPath, statusID in
|
||||
guard
|
||||
let self = self,
|
||||
let cell = tableView.dequeueReusableCell(
|
||||
withIdentifier: String(describing: StatusListCell.self),
|
||||
for: indexPath) as? StatusListCell
|
||||
else { return nil }
|
||||
withIdentifier: String(describing: item.kind.cellClass),
|
||||
for: indexPath)
|
||||
|
||||
cell.viewModel = self.viewModel.statusViewModel(id: statusID)
|
||||
switch (cell, cellViewModel) {
|
||||
case (let statusListCell as StatusListCell, let statusViewModel as StatusViewModel):
|
||||
statusListCell.viewModel = statusViewModel
|
||||
case (let accountListCell as AccountListCell, let accountViewModel as AccountViewModel):
|
||||
accountListCell.viewModel = accountViewModel
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
|
||||
return cell
|
||||
}
|
||||
}()
|
||||
|
||||
init(viewModel: StatusListViewModel) {
|
||||
init(viewModel: CollectionViewModel) {
|
||||
self.viewModel = viewModel
|
||||
|
||||
super.init(style: .plain)
|
||||
|
@ -42,33 +48,35 @@ class StatusListViewController: UITableViewController {
|
|||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
tableView.register(StatusListCell.self, forCellReuseIdentifier: String(describing: StatusListCell.self))
|
||||
for kind in CollectionItem.Kind.allCases {
|
||||
tableView.register(kind.cellClass, forCellReuseIdentifier: String(describing: kind.cellClass))
|
||||
}
|
||||
|
||||
tableView.dataSource = dataSource
|
||||
tableView.prefetchDataSource = self
|
||||
tableView.cellLayoutMarginsFollowReadableWidth = true
|
||||
tableView.tableFooterView = UIView()
|
||||
|
||||
navigationItem.title = viewModel.title
|
||||
// navigationItem.title = viewModel.title
|
||||
|
||||
viewModel.$statusIDs
|
||||
.sink { [weak self] in self?.update(statusIDs: $0) }
|
||||
viewModel.collectionItems
|
||||
.sink { [weak self] in self?.update(items: $0) }
|
||||
.store(in: &cancellables)
|
||||
|
||||
viewModel.events.sink { [weak self] in
|
||||
viewModel.navigationEvents.sink { [weak self] in
|
||||
guard let self = self else { return }
|
||||
switch $0 {
|
||||
case let .share(url):
|
||||
self.share(url: url)
|
||||
case let .statusListNavigation(statusListViewModel):
|
||||
self.show(StatusListViewController(viewModel: statusListViewModel), sender: self)
|
||||
case let .collectionNavigation(collectionViewModel):
|
||||
self.show(CollectionViewController(viewModel: collectionViewModel), sender: self)
|
||||
case let .urlNavigation(url):
|
||||
self.present(SFSafariViewController(url: url), animated: true)
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
viewModel.$loading
|
||||
viewModel.loading
|
||||
.receive(on: RunLoop.main)
|
||||
.sink { [weak self] in
|
||||
guard let self = self else { return }
|
||||
|
@ -97,7 +105,7 @@ class StatusListViewController: UITableViewController {
|
|||
override func viewWillAppear(_ animated: Bool) {
|
||||
super.viewWillAppear(animated)
|
||||
|
||||
viewModel.request()
|
||||
viewModel.request(maxID: nil, minID: nil)
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView,
|
||||
|
@ -105,7 +113,7 @@ class StatusListViewController: UITableViewController {
|
|||
forRowAt indexPath: IndexPath) {
|
||||
guard let item = dataSource.itemIdentifier(for: indexPath) else { return }
|
||||
|
||||
var heightCache = cellHeightCaches[tableView.frame.width] ?? [String: CGFloat]()
|
||||
var heightCache = cellHeightCaches[tableView.frame.width] ?? [CollectionItem: CGFloat]()
|
||||
|
||||
heightCache[item] = cell.frame.height
|
||||
cellHeightCaches[tableView.frame.width] = heightCache
|
||||
|
@ -118,15 +126,15 @@ class StatusListViewController: UITableViewController {
|
|||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, shouldHighlightRowAt indexPath: IndexPath) -> Bool {
|
||||
guard let id = dataSource.itemIdentifier(for: indexPath) else { return true }
|
||||
guard let item = dataSource.itemIdentifier(for: indexPath) else { return true }
|
||||
|
||||
return id != viewModel.contextParentID
|
||||
return viewModel.canSelect(item: item)
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
guard let id = dataSource.itemIdentifier(for: indexPath) else { return }
|
||||
guard let item = dataSource.itemIdentifier(for: indexPath) else { return }
|
||||
|
||||
show(StatusListViewController(viewModel: viewModel.contextViewModel(id: id)), sender: self)
|
||||
viewModel.itemSelected(item)
|
||||
}
|
||||
|
||||
override func viewDidLayoutSubviews() {
|
||||
|
@ -136,27 +144,27 @@ class StatusListViewController: UITableViewController {
|
|||
}
|
||||
}
|
||||
|
||||
extension StatusListViewController: UITableViewDataSourcePrefetching {
|
||||
extension CollectionViewController: UITableViewDataSourcePrefetching {
|
||||
func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) {
|
||||
guard
|
||||
viewModel.paginates,
|
||||
let indexPath = indexPaths.last,
|
||||
indexPath.section == dataSource.numberOfSections(in: tableView) - 1,
|
||||
indexPath.row == dataSource.tableView(tableView, numberOfRowsInSection: indexPath.section) - 1,
|
||||
let maxID = dataSource.itemIdentifier(for: indexPath)
|
||||
let maxID = dataSource.itemIdentifier(for: indexPath)?.id
|
||||
else { return }
|
||||
|
||||
viewModel.request(maxID: maxID)
|
||||
viewModel.request(maxID: maxID, minID: nil)
|
||||
}
|
||||
}
|
||||
|
||||
private extension StatusListViewController {
|
||||
func update(statusIDs: [[String]]) {
|
||||
private extension CollectionViewController {
|
||||
func update(items: [[CollectionItem]]) {
|
||||
var offsetFromNavigationBar: CGFloat?
|
||||
|
||||
if
|
||||
let id = viewModel.maintainScrollPositionOfStatusID,
|
||||
let indexPath = dataSource.indexPath(for: id),
|
||||
let item = viewModel.maintainScrollPositionOfItem,
|
||||
let indexPath = dataSource.indexPath(for: item),
|
||||
let navigationBar = navigationController?.navigationBar {
|
||||
let navigationBarMaxY = tableView.convert(navigationBar.bounds, from: navigationBar).maxY
|
||||
offsetFromNavigationBar = tableView.rectForRow(at: indexPath).origin.y - navigationBarMaxY
|
||||
|
@ -165,10 +173,10 @@ private extension StatusListViewController {
|
|||
dataSourceQueue.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
|
||||
self.dataSource.apply(statusIDs.snapshot(), animatingDifferences: false) {
|
||||
self.dataSource.apply(items.snapshot(), animatingDifferences: false) {
|
||||
if
|
||||
let id = self.viewModel.maintainScrollPositionOfStatusID,
|
||||
let indexPath = self.dataSource.indexPath(for: id) {
|
||||
let item = self.viewModel.maintainScrollPositionOfItem,
|
||||
let indexPath = self.dataSource.indexPath(for: item) {
|
||||
self.tableView.scrollToRow(at: indexPath, at: .top, animated: false)
|
||||
|
||||
if let offsetFromNavigationBar = offsetFromNavigationBar {
|
|
@ -0,0 +1,7 @@
|
|||
// Copyright © 2020 Metabolist. All rights reserved.
|
||||
|
||||
import Foundation
|
||||
|
||||
public class AccountListViewModel: ObservableObject {
|
||||
|
||||
}
|
|
@ -39,7 +39,7 @@ public class AccountStatusesViewModel: StatusListViewModel {
|
|||
}
|
||||
|
||||
override func isPinned(status: Status) -> Bool {
|
||||
collection == .statuses && statusIDs.first?.contains(status.id) ?? false
|
||||
collection == .statuses && items.first?.contains(CollectionItem(id: status.id, kind: .status)) ?? false
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,24 @@
|
|||
// Copyright © 2020 Metabolist. All rights reserved.
|
||||
|
||||
import Combine
|
||||
import Foundation
|
||||
import Mastodon
|
||||
import ServiceLayer
|
||||
|
||||
public class AccountViewModel: ObservableObject {
|
||||
private let accountService: AccountService
|
||||
|
||||
init(accountService: AccountService) {
|
||||
self.accountService = accountService
|
||||
}
|
||||
}
|
||||
|
||||
public extension AccountViewModel {
|
||||
var avatarURL: URL {
|
||||
accountService.account.avatar
|
||||
}
|
||||
|
||||
var note: NSAttributedString {
|
||||
accountService.account.note.attributed
|
||||
}
|
||||
}
|
|
@ -0,0 +1,17 @@
|
|||
// Copyright © 2020 Metabolist. All rights reserved.
|
||||
|
||||
import Combine
|
||||
import Foundation
|
||||
|
||||
public protocol CollectionViewModel {
|
||||
var collectionItems: AnyPublisher<[[CollectionItem]], Never> { get }
|
||||
var alertItems: AnyPublisher<AlertItem, Never> { get }
|
||||
var loading: AnyPublisher<Bool, Never> { get }
|
||||
var navigationEvents: AnyPublisher<NavigationEvent, Never> { get }
|
||||
var paginates: Bool { get }
|
||||
var maintainScrollPositionOfItem: CollectionItem? { get }
|
||||
func request(maxID: String?, minID: String?)
|
||||
func itemSelected(_ item: CollectionItem)
|
||||
func canSelect(item: CollectionItem) -> Bool
|
||||
func viewModel(item: CollectionItem) -> Any?
|
||||
}
|
|
@ -0,0 +1,13 @@
|
|||
// Copyright © 2020 Metabolist. All rights reserved.
|
||||
|
||||
public struct CollectionItem: Hashable {
|
||||
public let id: String
|
||||
public let kind: Kind
|
||||
}
|
||||
|
||||
public extension CollectionItem {
|
||||
enum Kind: Hashable, CaseIterable {
|
||||
case status
|
||||
case account
|
||||
}
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
// Copyright © 2020 Metabolist. All rights reserved.
|
||||
|
||||
import Foundation
|
||||
|
||||
public enum NavigationEvent {
|
||||
case collectionNavigation(CollectionViewModel)
|
||||
case urlNavigation(URL)
|
||||
case share(URL)
|
||||
}
|
|
@ -6,21 +6,22 @@ import Mastodon
|
|||
import ServiceLayer
|
||||
|
||||
public class StatusListViewModel: ObservableObject {
|
||||
@Published public private(set) var statusIDs = [[String]]()
|
||||
@Published public private(set) var items = [[CollectionItem]]()
|
||||
@Published public var alertItem: AlertItem?
|
||||
@Published public private(set) var loading = false
|
||||
public let events: AnyPublisher<Event, Never>
|
||||
public private(set) var maintainScrollPositionOfStatusID: String?
|
||||
public let navigationEvents: AnyPublisher<NavigationEvent, Never>
|
||||
public private(set) var maintainScrollPositionOfItem: CollectionItem?
|
||||
|
||||
private var statuses = [String: Status]()
|
||||
private var flatStatusIDs = [String]()
|
||||
private let statusListService: StatusListService
|
||||
private var statusViewModelCache = [Status: (StatusViewModel, AnyCancellable)]()
|
||||
private let eventsSubject = PassthroughSubject<Event, Never>()
|
||||
private let navigationEventsSubject = PassthroughSubject<NavigationEvent, Never>()
|
||||
private let loadingSubject = PassthroughSubject<Bool, Never>()
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
init(statusListService: StatusListService) {
|
||||
self.statusListService = statusListService
|
||||
events = eventsSubject.eraseToAnyPublisher()
|
||||
navigationEvents = navigationEventsSubject.eraseToAnyPublisher()
|
||||
|
||||
statusListService.statusSections
|
||||
.combineLatest(statusListService.filters.map { $0.regularExpression() })
|
||||
|
@ -29,11 +30,12 @@ public class StatusListViewModel: ObservableObject {
|
|||
self?.determineIfScrollPositionShouldBeMaintained(newStatusSections: $0)
|
||||
self?.cleanViewModelCache(newStatusSections: $0)
|
||||
self?.statuses = Dictionary(uniqueKeysWithValues: Set($0.reduce([], +)).map { ($0.id, $0) })
|
||||
self?.flatStatusIDs = $0.reduce([], +).map(\.id)
|
||||
})
|
||||
.receive(on: DispatchQueue.main)
|
||||
.assignErrorsToAlertItem(to: \.alertItem, on: self)
|
||||
.map { $0.map { $0.map(\.id) } }
|
||||
.assign(to: &$statusIDs)
|
||||
.map { $0.map { $0.map { CollectionItem(id: $0.id, kind: .status) } } }
|
||||
.assign(to: &$items)
|
||||
}
|
||||
|
||||
public func request(maxID: String? = nil, minID: String? = nil) {
|
||||
|
@ -41,8 +43,8 @@ public class StatusListViewModel: ObservableObject {
|
|||
.receive(on: DispatchQueue.main)
|
||||
.assignErrorsToAlertItem(to: \.alertItem, on: self)
|
||||
.handleEvents(
|
||||
receiveSubscription: { [weak self] _ in self?.loading = true },
|
||||
receiveCompletion: { [weak self] _ in self?.loading = false })
|
||||
receiveSubscription: { [weak self] _ in self?.loadingSubject.send(true) },
|
||||
receiveCompletion: { [weak self] _ in self?.loadingSubject.send(false) })
|
||||
.sink { _ in }
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
@ -50,11 +52,44 @@ public class StatusListViewModel: ObservableObject {
|
|||
func isPinned(status: Status) -> Bool { false }
|
||||
}
|
||||
|
||||
public extension StatusListViewModel {
|
||||
enum Event {
|
||||
case statusListNavigation(StatusListViewModel)
|
||||
case urlNavigation(URL)
|
||||
case share(URL)
|
||||
extension StatusListViewModel: CollectionViewModel {
|
||||
public var collectionItems: AnyPublisher<[[CollectionItem]], Never> { $items.eraseToAnyPublisher() }
|
||||
|
||||
public var alertItems: AnyPublisher<AlertItem, Never> { $alertItem.compactMap { $0 }.eraseToAnyPublisher() }
|
||||
|
||||
public var loading: AnyPublisher<Bool, Never> {
|
||||
loadingSubject.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
public func itemSelected(_ item: CollectionItem) {
|
||||
switch item.kind {
|
||||
case .status:
|
||||
let displayStatusID = statuses[item.id]?.displayStatus.id ?? item.id
|
||||
|
||||
navigationEventsSubject.send(
|
||||
.collectionNavigation(
|
||||
StatusListViewModel(
|
||||
statusListService: statusListService.contextService(statusID: displayStatusID))))
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
public func canSelect(item: CollectionItem) -> Bool {
|
||||
if case .status = item.kind, item.id == statusListService.contextParentID {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
public func viewModel(item: CollectionItem) -> Any? {
|
||||
switch item.kind {
|
||||
case .status:
|
||||
return statusViewModel(id: item.id)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -80,9 +115,9 @@ public extension StatusListViewModel {
|
|||
.assignErrorsToAlertItem(to: \.alertItem, on: self)
|
||||
.sink { [weak self] in
|
||||
guard let self = self,
|
||||
let event = self.event(statusEvent: $0)
|
||||
let event = self.navigationEvent(statusEvent: $0)
|
||||
else { return }
|
||||
self.eventsSubject.send(event)
|
||||
self.navigationEventsSubject.send(event)
|
||||
})
|
||||
}
|
||||
|
||||
|
@ -93,12 +128,6 @@ public extension StatusListViewModel {
|
|||
|
||||
return statusViewModel
|
||||
}
|
||||
|
||||
func contextViewModel(id: String) -> StatusListViewModel {
|
||||
let displayStatusID = statuses[id]?.displayStatus.id ?? id
|
||||
|
||||
return StatusListViewModel(statusListService: statusListService.contextService(statusID: displayStatusID))
|
||||
}
|
||||
}
|
||||
|
||||
private extension StatusListViewModel {
|
||||
|
@ -110,7 +139,7 @@ private extension StatusListViewModel {
|
|||
}
|
||||
}
|
||||
|
||||
func event(statusEvent: StatusViewModel.Event) -> Event? {
|
||||
func navigationEvent(statusEvent: StatusViewModel.Event) -> NavigationEvent? {
|
||||
switch statusEvent {
|
||||
case .ignorableOutput:
|
||||
return nil
|
||||
|
@ -119,30 +148,31 @@ private extension StatusListViewModel {
|
|||
case let .url(url):
|
||||
return .urlNavigation(url)
|
||||
case let .accountID(id):
|
||||
return .statusListNavigation(
|
||||
return .collectionNavigation(
|
||||
AccountStatusesViewModel(accountStatusesService: statusListService.service(accountID: id)))
|
||||
case let .statusID(id):
|
||||
return .statusListNavigation(
|
||||
return .collectionNavigation(
|
||||
StatusListViewModel(
|
||||
statusListService: statusListService.contextService(statusID: id)))
|
||||
case let .tag(tag):
|
||||
return .statusListNavigation(
|
||||
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]]) {
|
||||
maintainScrollPositionOfStatusID = nil // clear old value
|
||||
|
||||
let flatStatusIDs = statusIDs.reduce([], +)
|
||||
maintainScrollPositionOfItem = nil // clear old value
|
||||
|
||||
// Maintain scroll position of parent after initial load of context
|
||||
if let contextParentID = contextParentID, flatStatusIDs == [contextParentID] || flatStatusIDs == [] {
|
||||
maintainScrollPositionOfStatusID = contextParentID
|
||||
maintainScrollPositionOfItem = CollectionItem(id: contextParentID, kind: .status)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -153,8 +183,6 @@ private extension StatusListViewModel {
|
|||
}
|
||||
|
||||
func isReplyInContext(status: Status) -> Bool {
|
||||
let flatStatusIDs = statusIDs.reduce([], +)
|
||||
|
||||
guard
|
||||
let index = flatStatusIDs.firstIndex(where: { $0 == status.id }),
|
||||
index > 0
|
||||
|
@ -166,8 +194,6 @@ private extension StatusListViewModel {
|
|||
}
|
||||
|
||||
func hasReplyFollowing(status: Status) -> Bool {
|
||||
let flatStatusIDs = statusIDs.reduce([], +)
|
||||
|
||||
guard
|
||||
let index = flatStatusIDs.firstIndex(where: { $0 == status.id }),
|
||||
flatStatusIDs.count > index + 1,
|
||||
|
|
|
@ -53,6 +53,7 @@ public extension StatusViewModel {
|
|||
enum Event {
|
||||
case ignorableOutput
|
||||
case navigation(URLItem)
|
||||
case accountListNavigation(AccountListViewModel)
|
||||
case share(URL)
|
||||
}
|
||||
}
|
||||
|
@ -130,6 +131,10 @@ public extension StatusViewModel {
|
|||
.eraseToAnyPublisher())
|
||||
}
|
||||
|
||||
func favoritedBySelected() {
|
||||
|
||||
}
|
||||
|
||||
func toggleFavorited() {
|
||||
eventsSubject.send(statusService.toggleFavorited().map { _ in Event.ignorableOutput }.eraseToAnyPublisher())
|
||||
}
|
||||
|
|
|
@ -0,0 +1,18 @@
|
|||
// Copyright © 2020 Metabolist. All rights reserved.
|
||||
|
||||
import UIKit
|
||||
import ViewModels
|
||||
|
||||
struct AccountContentConfiguration {
|
||||
let viewModel: AccountViewModel
|
||||
}
|
||||
|
||||
extension AccountContentConfiguration: UIContentConfiguration {
|
||||
func makeContentView() -> UIView & UIContentView {
|
||||
AccountView(configuration: self)
|
||||
}
|
||||
|
||||
func updated(for state: UIConfigurationState) -> AccountContentConfiguration {
|
||||
self
|
||||
}
|
||||
}
|
|
@ -0,0 +1,23 @@
|
|||
// Copyright © 2020 Metabolist. All rights reserved.
|
||||
|
||||
import UIKit
|
||||
import ViewModels
|
||||
|
||||
class AccountListCell: UITableViewCell {
|
||||
var viewModel: AccountViewModel?
|
||||
|
||||
override func updateConfiguration(using state: UICellConfigurationState) {
|
||||
guard let viewModel = viewModel else { return }
|
||||
|
||||
contentConfiguration = AccountContentConfiguration(viewModel: viewModel).updated(for: state)
|
||||
}
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
|
||||
let isPhoneIdiom = UIDevice.current.userInterfaceIdiom == .phone
|
||||
|
||||
separatorInset.right = isPhoneIdiom ? 0 : layoutMargins.right
|
||||
separatorInset.left = isPhoneIdiom ? 0 : layoutMargins.left
|
||||
}
|
||||
}
|
|
@ -0,0 +1,64 @@
|
|||
// Copyright © 2020 Metabolist. All rights reserved.
|
||||
|
||||
import Kingfisher
|
||||
import UIKit
|
||||
|
||||
class AccountView: UIView {
|
||||
let avatarImageView = AnimatedImageView()
|
||||
let noteTextView = TouchFallthroughTextView()
|
||||
|
||||
private var accountConfiguration: AccountContentConfiguration
|
||||
|
||||
init(configuration: AccountContentConfiguration) {
|
||||
self.accountConfiguration = configuration
|
||||
|
||||
super.init(frame: .zero)
|
||||
|
||||
initialSetup()
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
}
|
||||
|
||||
extension AccountView: UIContentView {
|
||||
var configuration: UIContentConfiguration {
|
||||
get { accountConfiguration }
|
||||
set {
|
||||
guard let accountConfiguration = newValue as? AccountContentConfiguration else { return }
|
||||
|
||||
self.accountConfiguration = accountConfiguration
|
||||
|
||||
avatarImageView.kf.cancelDownloadTask()
|
||||
applyAccountConfiguration()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension AccountView {
|
||||
func initialSetup() {
|
||||
let baseStackView = UIStackView()
|
||||
|
||||
addSubview(baseStackView)
|
||||
baseStackView.translatesAutoresizingMaskIntoConstraints = false
|
||||
baseStackView.addArrangedSubview(avatarImageView)
|
||||
baseStackView.addArrangedSubview(noteTextView)
|
||||
noteTextView.isScrollEnabled = false
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
baseStackView.topAnchor.constraint(equalTo: readableContentGuide.topAnchor),
|
||||
baseStackView.leadingAnchor.constraint(equalTo: readableContentGuide.leadingAnchor),
|
||||
baseStackView.trailingAnchor.constraint(equalTo: readableContentGuide.trailingAnchor),
|
||||
baseStackView.bottomAnchor.constraint(equalTo: readableContentGuide.bottomAnchor)
|
||||
])
|
||||
|
||||
applyAccountConfiguration()
|
||||
}
|
||||
|
||||
func applyAccountConfiguration() {
|
||||
avatarImageView.kf.setImage(with: accountConfiguration.viewModel.avatarURL)
|
||||
noteTextView.attributedText = accountConfiguration.viewModel.note
|
||||
}
|
||||
}
|
|
@ -0,0 +1,26 @@
|
|||
// Copyright © 2020 Metabolist. All rights reserved.
|
||||
|
||||
import SwiftUI
|
||||
import ViewModels
|
||||
|
||||
struct CollectionView: UIViewControllerRepresentable {
|
||||
let viewModel: CollectionViewModel
|
||||
|
||||
func makeUIViewController(context: Context) -> CollectionViewController {
|
||||
CollectionViewController(viewModel: viewModel)
|
||||
}
|
||||
|
||||
func updateUIViewController(_ uiViewController: CollectionViewController, context: Context) {
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
import PreviewViewModels
|
||||
|
||||
struct StatusListView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
CollectionView(viewModel: NavigationViewModel(identification: .preview).viewModel(timeline: .home))
|
||||
}
|
||||
}
|
||||
#endif
|
|
@ -148,15 +148,20 @@ private extension StatusView {
|
|||
let accountAction = UIAction { [weak self] _ in self?.statusConfiguration.viewModel.accountSelected() }
|
||||
|
||||
avatarButton.addAction(accountAction, for: .touchUpInside)
|
||||
contextParentAvatarButton.addAction(accountAction, for: .touchUpInside)
|
||||
|
||||
let favoriteAction = UIAction { [weak self] _ in self?.statusConfiguration.viewModel.toggleFavorited() }
|
||||
|
||||
favoriteButton.addAction(favoriteAction, for: .touchUpInside)
|
||||
contextParentFavoriteButton.addAction(favoriteAction, for: .touchUpInside)
|
||||
|
||||
let shareAction = UIAction { [weak self] _ in self?.statusConfiguration.viewModel.shareStatus() }
|
||||
shareButton.addAction(
|
||||
UIAction { [weak self] _ in self?.statusConfiguration.viewModel.shareStatus() },
|
||||
for: .touchUpInside)
|
||||
|
||||
shareButton.addAction(shareAction, for: .touchUpInside)
|
||||
contextParentFavoritedByButton.addAction(
|
||||
UIAction { [weak self] _ in self?.statusConfiguration.viewModel.favoritedBySelected() },
|
||||
for: .touchUpInside)
|
||||
|
||||
applyStatusConfiguration()
|
||||
}
|
||||
|
|
|
@ -1,26 +0,0 @@
|
|||
// Copyright © 2020 Metabolist. All rights reserved.
|
||||
|
||||
import SwiftUI
|
||||
import ViewModels
|
||||
|
||||
struct StatusListView: UIViewControllerRepresentable {
|
||||
let viewModel: StatusListViewModel
|
||||
|
||||
func makeUIViewController(context: Context) -> StatusListViewController {
|
||||
StatusListViewController(viewModel: viewModel)
|
||||
}
|
||||
|
||||
func updateUIViewController(_ uiViewController: StatusListViewController, context: Context) {
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
import PreviewViewModels
|
||||
|
||||
struct StatusListView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
StatusListView(viewModel: NavigationViewModel(identification: .preview).viewModel(timeline: .home))
|
||||
}
|
||||
}
|
||||
#endif
|
|
@ -61,7 +61,7 @@ private extension TabNavigationView {
|
|||
func view(tab: NavigationViewModel.Tab) -> some View {
|
||||
switch tab {
|
||||
case .timelines:
|
||||
StatusListView(viewModel: viewModel.viewModel(timeline: viewModel.timeline))
|
||||
CollectionView(viewModel: viewModel.viewModel(timeline: viewModel.timeline))
|
||||
.id(viewModel.timeline.id)
|
||||
.edgesIgnoringSafeArea(.all)
|
||||
.navigationTitle(viewModel.timeline.title)
|
||||
|
|
Loading…
Reference in New Issue