From 4211806b5f95a75642340c45ea039a0fce00ea8a Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Wed, 18 Jan 2023 14:35:50 -0500 Subject: [PATCH] Add followers/following screen Closes #323 --- Tusker.xcodeproj/project.pbxproj | 16 + .../MastodonCachePersistentStore.swift | 8 + .../AccountFollowsListViewController.swift | 277 ++++++++++++++++++ .../AccountFollowsViewController.swift | 46 +++ .../Profile Header/ProfileHeaderView.swift | 2 + 5 files changed, 349 insertions(+) create mode 100644 Tusker/Screens/Account Follows/AccountFollowsListViewController.swift create mode 100644 Tusker/Screens/Account Follows/AccountFollowsViewController.swift diff --git a/Tusker.xcodeproj/project.pbxproj b/Tusker.xcodeproj/project.pbxproj index 43a128af..c19d66a6 100644 --- a/Tusker.xcodeproj/project.pbxproj +++ b/Tusker.xcodeproj/project.pbxproj @@ -17,6 +17,7 @@ 04DACE8C212CB14B009840C4 /* MainTabBarViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04DACE8B212CB14B009840C4 /* MainTabBarViewController.swift */; }; 04DACE8E212CC7CC009840C4 /* ImageCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04DACE8D212CC7CC009840C4 /* ImageCache.swift */; }; 04ED00B121481ED800567C53 /* SteppedProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04ED00B021481ED800567C53 /* SteppedProgressView.swift */; }; + D601FA5B29787AB100A8E8B5 /* AccountFollowsListViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D601FA5A29787AB100A8E8B5 /* AccountFollowsListViewController.swift */; }; D6093F9B25BDD4B9004811E6 /* HashtagSearchResultsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6093F9A25BDD4B9004811E6 /* HashtagSearchResultsViewController.swift */; }; D6093FB725BE0CF3004811E6 /* TrendHistoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6093FB625BE0CF3004811E6 /* TrendHistoryView.swift */; }; D60CFFDB24A290BA00D00083 /* SwiftSoup in Frameworks */ = {isa = PBXBuildFile; productRef = D60CFFDA24A290BA00D00083 /* SwiftSoup */; }; @@ -155,6 +156,7 @@ D65B4B6629773AE600DABDFB /* DeleteStatusService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D65B4B6529773AE600DABDFB /* DeleteStatusService.swift */; }; D65B4B682977769E00DABDFB /* StatusActionAccountListViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D65B4B672977769E00DABDFB /* StatusActionAccountListViewController.swift */; }; D65B4B6A297777D900DABDFB /* StatusNotFoundView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D65B4B69297777D900DABDFB /* StatusNotFoundView.swift */; }; + D65B4B8B297879E900DABDFB /* AccountFollowsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D65B4B8A297879E900DABDFB /* AccountFollowsViewController.swift */; }; D65C6BF525478A9C00A6E89C /* BackgroundableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D65C6BF425478A9C00A6E89C /* BackgroundableViewController.swift */; }; D65F613423AEAB6600F3CFD3 /* OnboardingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D65F613323AEAB6600F3CFD3 /* OnboardingTests.swift */; }; D6620ACE2511A0ED00312CA0 /* StatusStateResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6620ACD2511A0ED00312CA0 /* StatusStateResolver.swift */; }; @@ -413,6 +415,7 @@ 04DACE8B212CB14B009840C4 /* MainTabBarViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainTabBarViewController.swift; sourceTree = ""; }; 04DACE8D212CC7CC009840C4 /* ImageCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageCache.swift; sourceTree = ""; }; 04ED00B021481ED800567C53 /* SteppedProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SteppedProgressView.swift; sourceTree = ""; }; + D601FA5A29787AB100A8E8B5 /* AccountFollowsListViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountFollowsListViewController.swift; sourceTree = ""; }; D6093F9A25BDD4B9004811E6 /* HashtagSearchResultsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HashtagSearchResultsViewController.swift; sourceTree = ""; }; D6093FB625BE0CF3004811E6 /* TrendHistoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrendHistoryView.swift; sourceTree = ""; }; D60D2B8123844C71001B87A3 /* BaseStatusTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BaseStatusTableViewCell.swift; sourceTree = ""; }; @@ -547,6 +550,7 @@ D65B4B6529773AE600DABDFB /* DeleteStatusService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeleteStatusService.swift; sourceTree = ""; }; D65B4B672977769E00DABDFB /* StatusActionAccountListViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusActionAccountListViewController.swift; sourceTree = ""; }; D65B4B69297777D900DABDFB /* StatusNotFoundView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusNotFoundView.swift; sourceTree = ""; }; + D65B4B8A297879E900DABDFB /* AccountFollowsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountFollowsViewController.swift; sourceTree = ""; }; D65C6BF425478A9C00A6E89C /* BackgroundableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundableViewController.swift; sourceTree = ""; }; D65F612D23AE990C00F3CFD3 /* Embassy.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = Embassy.framework; sourceTree = BUILT_PRODUCTS_DIR; }; D65F613023AE99E000F3CFD3 /* Ambassador.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = Ambassador.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -966,6 +970,7 @@ D641C780213DD7C4004B4513 /* Screens */ = { isa = PBXGroup; children = ( + D65B4B89297879DE00DABDFB /* Account Follows */, D6A3BC822321F69400FD64D5 /* Account List */, D6B053A023BD2BED00A066FA /* Asset Picker */, 0411610522B457290030A9B7 /* Attachment Gallery */, @@ -1207,6 +1212,15 @@ path = Report; sourceTree = ""; }; + D65B4B89297879DE00DABDFB /* Account Follows */ = { + isa = PBXGroup; + children = ( + D65B4B8A297879E900DABDFB /* AccountFollowsViewController.swift */, + D601FA5A29787AB100A8E8B5 /* AccountFollowsListViewController.swift */, + ); + path = "Account Follows"; + sourceTree = ""; + }; D663626021360A9600C9CBA2 /* Preferences */ = { isa = PBXGroup; children = ( @@ -2024,6 +2038,7 @@ D627944A23A6AD6100D38C68 /* BookmarksTableViewController.swift in Sources */, D63CC7102911F1E4000E19DE /* UIScrollView+Top.swift in Sources */, D6F6A557291F4F1600F496A8 /* MuteAccountView.swift in Sources */, + D65B4B8B297879E900DABDFB /* AccountFollowsViewController.swift in Sources */, D6945C3A23AC75E2005C403C /* FindInstanceViewController.swift in Sources */, D65B4B6429771EFF00DABDFB /* ConversationViewController.swift in Sources */, D68E6F59253C9969001A1B4C /* MultiSourceEmojiLabel.swift in Sources */, @@ -2060,6 +2075,7 @@ D6969EA0240C8384002843CE /* EmojiLabel.swift in Sources */, D64BC18623C1253A000D0238 /* AssetPreviewViewController.swift in Sources */, D6F6A550291F058600F496A8 /* CreateListService.swift in Sources */, + D601FA5B29787AB100A8E8B5 /* AccountFollowsListViewController.swift in Sources */, D6BEA24B291C6A2B002F4D01 /* AlertWithData.swift in Sources */, D61ABEFE28F1C92600B29151 /* FavoriteService.swift in Sources */, D61F75AB293AF11400C0B37F /* FilterKeywordMO.swift in Sources */, diff --git a/Tusker/CoreData/MastodonCachePersistentStore.swift b/Tusker/CoreData/MastodonCachePersistentStore.swift index b61148ed..0e0c94ab 100644 --- a/Tusker/CoreData/MastodonCachePersistentStore.swift +++ b/Tusker/CoreData/MastodonCachePersistentStore.swift @@ -348,6 +348,14 @@ class MastodonCachePersistentStore: NSPersistentCloudKitContainer { accounts.forEach { self.accountSubject.send($0.id) } } } + + func addAll(accounts: [Account], in context: NSManagedObjectContext? = nil) async { + await withCheckedContinuation { continuation in + addAll(accounts: accounts, in: context) { + continuation.resume() + } + } + } func addAll(notifications: [Pachyderm.Notification], completion: (() -> Void)? = nil) { backgroundContext.perform { diff --git a/Tusker/Screens/Account Follows/AccountFollowsListViewController.swift b/Tusker/Screens/Account Follows/AccountFollowsListViewController.swift new file mode 100644 index 00000000..a5b9045f --- /dev/null +++ b/Tusker/Screens/Account Follows/AccountFollowsListViewController.swift @@ -0,0 +1,277 @@ +// +// AccountFollowsListViewController.swift +// Tusker +// +// Created by Shadowfacts on 1/18/23. +// Copyright © 2023 Shadowfacts. All rights reserved. +// + +import UIKit +import Pachyderm + +class AccountFollowsListViewController: UIViewController, CollectionViewController { + + let accountID: String + let mastodonController: MastodonController + let mode: AccountFollowsViewController.Mode + + var collectionView: UICollectionView! { + view as? UICollectionView + } + private var dataSource: UICollectionViewDiffableDataSource! + + private var state: State = .unloaded + private var newer: RequestRange? + private var older: RequestRange? + + init(accountID: String, mastodonController: MastodonController, mode: AccountFollowsViewController.Mode) { + self.accountID = accountID + self.mastodonController = mastodonController + self.mode = mode + + super.init(nibName: nil, bundle: nil) + + title = mode.title + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func loadView() { + var config = UICollectionLayoutListConfiguration(appearance: .grouped) + config.itemSeparatorHandler = { [unowned self] indexPath, sectionConfig in + guard let item = self.dataSource.itemIdentifier(for: indexPath) else { + return sectionConfig + } + var config = sectionConfig + if item.hideSeparators { + config.topSeparatorVisibility = .hidden + config.bottomSeparatorVisibility = .hidden + } + return config + } + let layout = UICollectionViewCompositionalLayout.list(using: config) + view = UICollectionView(frame: .zero, collectionViewLayout: layout) + collectionView.delegate = self + collectionView.dragDelegate = self + collectionView.allowsFocus = true + dataSource = createDataSource() + } + + private func createDataSource() -> UICollectionViewDiffableDataSource { + let accountCell = UICollectionView.CellRegistration { [unowned self] cell, indexPath, item in + cell.delegate = self + cell.updateUI(accountID: item) + } + let loadingCell = UICollectionView.CellRegistration { cell, indexPath, item in + cell.indicator.startAnimating() + } + return UICollectionViewDiffableDataSource(collectionView: collectionView, cellProvider: { collectionView, indexPath, itemIdentifier in + switch itemIdentifier { + case .account(let id): + return collectionView.dequeueConfiguredReusableCell(using: accountCell, for: indexPath, item: id) + case .loadingIndicator: + return collectionView.dequeueConfiguredReusableCell(using: loadingCell, for: indexPath, item: ()) + } + }) + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + clearSelectionOnAppear(animated: animated) + + if case .unloaded = state { + Task { + await loadInitial() + } + } + } + + private func request(for range: RequestRange) -> Request<[Account]> { + switch mode { + case .following: + return Account.getFollowing(accountID, range: range) + case .followers: + return Account.getFollowers(accountID, range: range) + } + } + + private func apply(snapshot: NSDiffableDataSourceSnapshot) async { + await Task { @MainActor in + self.dataSource.apply(snapshot) + }.value + } + + @MainActor + private func loadInitial() async { + guard case .unloaded = state else { + return + } + + self.state = .loadingInitial + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([.accounts]) + snapshot.appendItems([.loadingIndicator]) + await apply(snapshot: snapshot) + + do { + let (accounts, pagination) = try await mastodonController.run(request(for: .default)) + await mastodonController.persistentContainer.addAll(accounts: accounts) + + guard case .loadingInitial = self.state else { + return + } + self.state = .loaded + self.newer = pagination?.newer + self.older = pagination?.older + + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([.accounts]) + snapshot.appendItems(accounts.map { .account($0.id) }) + await apply(snapshot: snapshot) + + } catch { + self.state = .unloaded + + let config = ToastConfiguration(from: error, with: "Error Loading Accounts", in: self) { [unowned self] toast in + toast.dismissToast(animated: true) + await self.loadInitial() + } + self.showToast(configuration: config, animated: true) + } + } + + @MainActor + private func loadOlder() async { + guard case .loaded = state, + let older else { + return + } + + self.state = .loadingOlder + var snapshot = dataSource.snapshot() + snapshot.appendItems([.loadingIndicator]) + await apply(snapshot: snapshot) + + do { + let (accounts, pagination) = try await mastodonController.run(request(for: older)) + await mastodonController.persistentContainer.addAll(accounts: accounts) + + guard case .loadingOlder = self.state else { + return + } + self.state = .loaded + self.older = pagination?.older + + var snapshot = dataSource.snapshot() + snapshot.deleteItems([.loadingIndicator]) + snapshot.appendItems(accounts.map { .account($0.id) }) + await apply(snapshot: snapshot) + + } catch { + self.state = .loaded + + let config = ToastConfiguration(from: error, with: "Error Loading More", in: self) { [unowned self] toast in + toast.dismissToast(animated: true) + await self.loadOlder() + } + self.showToast(configuration: config, animated: true) + } + } +} + +extension AccountFollowsListViewController { + enum State { + case unloaded + case loadingInitial + case loaded + case loadingOlder + } +} + +extension AccountFollowsListViewController { + enum Section { + case accounts + } + enum Item: Hashable { + case account(String) + case loadingIndicator + + var hideSeparators: Bool { + switch self { + case .account(_): + return false + case .loadingIndicator: + return true + } + } + } +} + +extension AccountFollowsListViewController: UICollectionViewDelegate { + func collectionView(_ collectionView: UICollectionView, willDisplay cell: UICollectionViewCell, forItemAt indexPath: IndexPath) { + if indexPath.section == collectionView.numberOfSections - 1, + indexPath.row == collectionView.numberOfItems(inSection: indexPath.section) - 1 { + Task { + await self.loadOlder() + } + } + } + + func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + guard case .account(let id) = dataSource.itemIdentifier(for: indexPath) else { + return + } + selected(account: id) + } + + func collectionView(_ collectionView: UICollectionView, contextMenuConfigurationForItemAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? { + guard case .account(let id) = dataSource.itemIdentifier(for: indexPath), + let cell = collectionView.cellForItem(at: indexPath) else { + return nil + } + return UIContextMenuConfiguration { + ProfileViewController(accountID: id, mastodonController: self.mastodonController) + } actionProvider: { _ in + UIMenu(children: self.actionsForProfile(accountID: id, source: .view(cell))) + } + } + + func collectionView(_ collectionView: UICollectionView, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) { + MenuPreviewHelper.willPerformPreviewAction(animator: animator, presenter: self) + } +} + +extension AccountFollowsListViewController: UICollectionViewDragDelegate { + func collectionView(_ collectionView: UICollectionView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath) -> [UIDragItem] { + guard case .account(let id) = dataSource.itemIdentifier(for: indexPath), + let currentAccountID = mastodonController.accountInfo?.id, + let account = mastodonController.persistentContainer.account(for: id) else { + return [] + } + let provider = NSItemProvider(object: account.url as NSURL) + let activity = UserActivityManager.showProfileActivity(id: id, accountID: currentAccountID) + activity.displaysAuxiliaryScene = true + provider.registerObject(activity, visibility: .all) + return [UIDragItem(itemProvider: provider)] + } +} + +extension AccountFollowsListViewController: TuskerNavigationDelegate { + var apiController: MastodonController! { mastodonController } +} + +extension AccountFollowsListViewController: MenuActionProvider { +} + +extension AccountFollowsListViewController: ToastableViewController { +} + +extension AccountFollowsListViewController: StatusBarTappableViewController { + func handleStatusBarTapped(xPosition: CGFloat) -> StatusBarTapActionResult { + collectionView.scrollToTop() + return .stop + } +} diff --git a/Tusker/Screens/Account Follows/AccountFollowsViewController.swift b/Tusker/Screens/Account Follows/AccountFollowsViewController.swift new file mode 100644 index 00000000..6077b85d --- /dev/null +++ b/Tusker/Screens/Account Follows/AccountFollowsViewController.swift @@ -0,0 +1,46 @@ +// +// AccountFollowsViewController.swift +// Tusker +// +// Created by Shadowfacts on 1/18/23. +// Copyright © 2023 Shadowfacts. All rights reserved. +// + +import UIKit + +class AccountFollowsViewController: SegmentedPageViewController { + + let accountID: String + let mastodonController: MastodonController + + init(accountID: String, mastodonController: MastodonController) { + self.accountID = accountID + self.mastodonController = mastodonController + + super.init(pages: [.following, .followers]) { mode in + AccountFollowsListViewController(accountID: accountID, mastodonController: mastodonController, mode: mode) + } + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + +} + +extension AccountFollowsViewController { + enum Mode: SegmentedPageViewControllerPage { + case following, followers + + var title: String { + switch self { + case .following: + return "Following" + case .followers: + return "Followers" + } + } + + var segmentedControlTitle: String { title } + } +} diff --git a/Tusker/Views/Profile Header/ProfileHeaderView.swift b/Tusker/Views/Profile Header/ProfileHeaderView.swift index bcf455ca..172886f7 100644 --- a/Tusker/Views/Profile Header/ProfileHeaderView.swift +++ b/Tusker/Views/Profile Header/ProfileHeaderView.swift @@ -351,6 +351,8 @@ class ProfileHeaderView: UIView { } @IBAction func followCountButtonPressed(_ sender: Any) { + guard let accountID else { return } + delegate?.show(AccountFollowsViewController(accountID: accountID, mastodonController: mastodonController)) } }