diff --git a/Tusker/CoreData/MastodonCachePersistentStore.swift b/Tusker/CoreData/MastodonCachePersistentStore.swift index a885d5bf..4491d59f 100644 --- a/Tusker/CoreData/MastodonCachePersistentStore.swift +++ b/Tusker/CoreData/MastodonCachePersistentStore.swift @@ -35,6 +35,9 @@ class MastodonCachePersistentStore: NSPersistentContainer { return context }() + // TODO: consider sending managed objects through this to avoid re-fetching things unnecessarily + // would need to audit existing uses to make sure everything happens on the main thread + // and when updating things on the background context would need to switch to main, refetch, and then publish let statusSubject = PassthroughSubject() let accountSubject = PassthroughSubject() let relationshipSubject = PassthroughSubject() diff --git a/Tusker/Screens/Profile/MyProfileViewController.swift b/Tusker/Screens/Profile/MyProfileViewController.swift index f0c02ff6..cd641b7f 100644 --- a/Tusker/Screens/Profile/MyProfileViewController.swift +++ b/Tusker/Screens/Profile/MyProfileViewController.swift @@ -9,7 +9,7 @@ import UIKit import Pachyderm -class MyProfileViewController: ProfileViewController { +class MyProfileViewController: NewProfileViewController { init(mastodonController: MastodonController) { super.init(accountID: nil, mastodonController: mastodonController) diff --git a/Tusker/Screens/Profile/NewProfileStatusesViewController.swift b/Tusker/Screens/Profile/NewProfileStatusesViewController.swift index 3e900130..f055448f 100644 --- a/Tusker/Screens/Profile/NewProfileStatusesViewController.swift +++ b/Tusker/Screens/Profile/NewProfileStatusesViewController.swift @@ -30,15 +30,6 @@ class NewProfileStatusesViewController: UIViewController, TimelineLikeCollection } private(set) var dataSource: UICollectionViewDiffableDataSource! -// var headerCell: NewProfileHeaderCollectionViewCell? { -// guard let accountID, -// isViewLoaded, -// let indexPath = dataSource.indexPath(for: .header(accountID)), -// let cell = collectionView.cellForItem(at: indexPath) as? NewProfileHeaderCollectionViewCell else { -// return nil -// } -// return cell -// } private(set) var headerCell: NewProfileHeaderCollectionViewCell? init(accountID: String?, kind: Kind, owner: NewProfileViewController) { @@ -60,7 +51,7 @@ class NewProfileStatusesViewController: UIViewController, TimelineLikeCollection } .store(in: &cancellables) - // TODO: refresh key command + addKeyCommand(MenuController.refreshCommand(discoverabilityTitle: "Refresh Profile")) } required init?(coder: NSCoder) { @@ -75,16 +66,33 @@ class NewProfileStatusesViewController: UIViewController, TimelineLikeCollection config.trailingSwipeActionsConfigurationProvider = { [unowned self] in (collectionView.cellForItem(at: $0) as? TimelineStatusCollectionViewCell)?.trailingSwipeActions() } - // TODO: item separators + config.itemSeparatorHandler = { [unowned self] indexPath, sectionSeparatorConfiguration in + guard let item = self.dataSource.itemIdentifier(for: indexPath) else { + return sectionSeparatorConfiguration + } + var config = sectionSeparatorConfiguration + if item.hideSeparators { + config.topSeparatorVisibility = .hidden + config.bottomSeparatorVisibility = .hidden + } + if case .status(_, _, _) = item { + config.topSeparatorInsets = TimelineStatusCollectionViewCell.separatorInsets + config.bottomSeparatorInsets = TimelineStatusCollectionViewCell.separatorInsets + } + return config + } let layout = UICollectionViewCompositionalLayout.list(using: config) view = UICollectionView(frame: .zero, collectionViewLayout: layout) collectionView.delegate = self - // TODO: drag delegate + collectionView.dragDelegate = self registerTimelineLikeCells() dataSource = createDataSource() - // TODO: refresh control + #if !targetEnvironment(macCatalyst) + collectionView.refreshControl = UIRefreshControl() + collectionView.refreshControl!.addTarget(self, action: #selector(refresh), for: .valueChanged) + #endif } override func viewDidLoad() { @@ -93,11 +101,6 @@ class NewProfileStatusesViewController: UIViewController, TimelineLikeCollection } private func createDataSource() -> UICollectionViewDiffableDataSource { -// let headerCell = UICollectionView.CellRegistration { [unowned self] cell, indexPath, item in -// cell.header.delegate = self.profileHeaderDelegate -// cell.header.updateUI(for: item) -// cell.header.pagesSegmentedControl.selectedSegmentIndex = self.owner.currentIndex ?? 0 -// } collectionView.register(NewProfileHeaderCollectionViewCell.self, forCellWithReuseIdentifier: "headerCell") let statusCell = UICollectionView.CellRegistration { [unowned self] cell, indexPath, item in cell.delegate = self @@ -148,14 +151,6 @@ class NewProfileStatusesViewController: UIViewController, TimelineLikeCollection } } - override func viewDidDisappear(_ animated: Bool) { - super.viewDidDisappear(animated) - - // TODO: prune offscreen rows - } - - // TODO: refreshing - func setAccountID(_ id: String) { self.accountID = id // TODO: maybe this function should be async? @@ -166,7 +161,8 @@ class NewProfileStatusesViewController: UIViewController, TimelineLikeCollection private func load() async { guard accountID != nil, - await controller.state == .notLoadedInitial else { + await controller.state == .notLoadedInitial, + isViewLoaded else { return } @@ -213,6 +209,18 @@ class NewProfileStatusesViewController: UIViewController, TimelineLikeCollection await apply(snapshot, animatingDifferences: true) } + @objc func refresh() { + Task { + // TODO: coalesce these data source updates + // TODO: refresh profile + await controller.loadNewer() + await tryLoadPinned() + #if !targetEnvironment(macCatalyst) + collectionView.refreshControl?.endRefreshing() + #endif + } + } + } extension NewProfileStatusesViewController { @@ -275,6 +283,24 @@ extension NewProfileStatusesViewController { hasher.combine(3) } } + + var hideSeparators: Bool { + switch self { + case .loadingIndicator, .confirmLoadMore: + return true + default: + return false + } + } + + var isSelectable: Bool { + switch self { + case .status(id: _, state: _, pinned: _): + return true + default: + return false + } + } } } @@ -371,7 +397,31 @@ extension NewProfileStatusesViewController: UICollectionViewDelegate { } } - // TODO: cell selection + func collectionView(_ collectionView: UICollectionView, shouldSelectItemAt indexPath: IndexPath) -> Bool { + return dataSource.itemIdentifier(for: indexPath)?.isSelectable ?? false + } + + func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + guard case .status(id: let id, state: let state, pinned: _) = dataSource.itemIdentifier(for: indexPath) else { + return + } + let status = mastodonController.persistentContainer.status(for: id)! + selected(status: status.reblog?.id ?? id, state: state.copy()) + } + + func collectionView(_ collectionView: UICollectionView, contextMenuConfigurationForItemAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? { + return (collectionView.cellForItem(at: indexPath) as? TimelineStatusCollectionViewCell)?.contextMenuConfiguration() + } + + func collectionView(_ collectionView: UICollectionView, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) { + MenuPreviewHelper.willPerformPreviewAction(animator: animator, presenter: self) + } +} + +extension NewProfileStatusesViewController: UICollectionViewDragDelegate { + func collectionView(_ collectionView: UICollectionView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath) -> [UIDragItem] { + (collectionView.cellForItem(at: indexPath) as? TimelineStatusCollectionViewCell)?.dragItemsForBeginning(session: session) ?? [] + } } extension NewProfileStatusesViewController: TuskerNavigationDelegate { diff --git a/Tusker/Screens/Profile/NewProfileViewController.swift b/Tusker/Screens/Profile/NewProfileViewController.swift index 23fc0da4..f58a170c 100644 --- a/Tusker/Screens/Profile/NewProfileViewController.swift +++ b/Tusker/Screens/Profile/NewProfileViewController.swift @@ -8,6 +8,7 @@ import UIKit import Pachyderm +import Combine class NewProfileViewController: UIPageViewController { @@ -35,6 +36,8 @@ class NewProfileViewController: UIPageViewController { private var state: State = .idle + private var cancellables = Set() + init(accountID: String?, mastodonController: MastodonController) { self.accountID = accountID self.mastodonController = mastodonController @@ -46,6 +49,12 @@ class NewProfileViewController: UIPageViewController { .init(accountID: accountID, kind: .withReplies, owner: self), .init(accountID: accountID, kind: .onlyMedia, owner: self), ] + + // try to update the account UI immediately if possible, to avoid the navigation title popping in later + if let accountID, + let account = mastodonController.persistentContainer.account(for: accountID) { + updateAccountUI(account: account) + } } required init?(coder: NSCoder) { @@ -63,15 +72,37 @@ class NewProfileViewController: UIPageViewController { selectPage(at: 0, animated: false) - // TODO: compose button + let composeButton = UIBarButtonItem(barButtonSystemItem: .compose, target: self, action: #selector(composeMentioning)) + composeButton.menu = UIMenu(children: [ + UIAction(title: "Direct Message", image: UIImage(systemName: Status.Visibility.direct.unfilledImageName), handler: { [unowned self] _ in + self.composeDirectMentioning() + }) + ]) + composeButton.isEnabled = mastodonController.loggedIn + navigationItem.rightBarButtonItem = composeButton - // TODO: key commands + addKeyCommand(MenuController.prevSubTabCommand) + addKeyCommand(MenuController.nextSubTabCommand) + + mastodonController.persistentContainer.accountSubject + .receive(on: DispatchQueue.main) + .filter { [unowned self] in $0 == self.accountID } + .sink { [unowned self] id in + let account = self.mastodonController.persistentContainer.account(for: id)! + self.updateAccountUI(account: account) + } + .store(in: &cancellables) Task { await loadAccount() } - // TODO: configure nav controller appearance + // disable the transparent nav bar because it gets messy with multiple pages at different scroll positions + if let nav = navigationController { + let appearance = UINavigationBarAppearance() + appearance.configureWithDefaultBackground() + nav.navigationBar.scrollEdgeAppearance = appearance + } } private func loadAccount() async { @@ -221,11 +252,30 @@ class NewProfileViewController: UIPageViewController { } } + // MARK: Interaction + + @objc private func composeMentioning() { + if let accountID, + let account = mastodonController.persistentContainer.account(for: accountID) { + compose(mentioningAcct: account.acct) + } + } + + private func composeDirectMentioning() { + if let accountID, + let account = mastodonController.persistentContainer.account(for: accountID) { + let draft = mastodonController.createDraft(mentioningAcct: account.acct) + draft.visibility = .direct + compose(editing: draft) + } + } +} + +extension NewProfileViewController { enum State { case idle case animating } - } extension NewProfileViewController: TuskerNavigationDelegate { @@ -243,3 +293,15 @@ extension NewProfileViewController: ProfileHeaderViewDelegate { selectPage(at: newIndex, animated: true) } } + +extension NewProfileViewController: TabbedPageViewController { + func selectNextPage() { + guard currentIndex < pageControllers.count - 1 else { return } + selectPage(at: currentIndex + 1, animated: true) + } + + func selectPrevPage() { + guard currentIndex > 0 else { return } + selectPage(at: currentIndex - 1, animated: true) + } +} diff --git a/Tusker/Screens/Timeline/TimelineViewController.swift b/Tusker/Screens/Timeline/TimelineViewController.swift index 3f027050..b9d32eae 100644 --- a/Tusker/Screens/Timeline/TimelineViewController.swift +++ b/Tusker/Screens/Timeline/TimelineViewController.swift @@ -61,8 +61,8 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro config.bottomSeparatorVisibility = .hidden } if case .status(_, _) = item { - config.topSeparatorInsets = NSDirectionalEdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 0) - config.bottomSeparatorInsets = NSDirectionalEdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 0) + config.topSeparatorInsets = TimelineStatusCollectionViewCell.separatorInsets + config.bottomSeparatorInsets = TimelineStatusCollectionViewCell.separatorInsets } return config } @@ -250,7 +250,7 @@ extension TimelineViewController { var hideSeparators: Bool { switch self { - case .loadingIndicator, .publicTimelineDescription: + case .loadingIndicator, .publicTimelineDescription, .confirmLoadMore: return true default: return false diff --git a/Tusker/Views/Status/TimelineStatusCollectionViewCell.swift b/Tusker/Views/Status/TimelineStatusCollectionViewCell.swift index 1b49b116..920bf487 100644 --- a/Tusker/Views/Status/TimelineStatusCollectionViewCell.swift +++ b/Tusker/Views/Status/TimelineStatusCollectionViewCell.swift @@ -12,6 +12,8 @@ import Combine class TimelineStatusCollectionViewCell: UICollectionViewListCell, StatusCollectionViewCell { + static let separatorInsets = NSDirectionalEdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 0) + // MARK: Subviews private lazy var reblogLabel = EmojiLabel().configure {