From 848c3dd9508dec5e8b7b2f08a1d52ba047b14cb4 Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Tue, 22 Nov 2022 15:21:36 -0500 Subject: [PATCH] Rewrite status action account list to use UICollectionView --- Tusker.xcodeproj/project.pbxproj | 12 +- ...ActionAccountListTableViewController.swift | 163 ----------- ...tatusActionAccountListViewController.swift | 266 ++++++++++++++++++ Tusker/TuskerNavigationDelegate.swift | 4 +- .../AccountCollectionViewCell.swift | 148 ++++++++++ Tusker/Views/CachedImageView.swift | 1 + ...ActionNotificationGroupTableViewCell.swift | 12 +- 7 files changed, 434 insertions(+), 172 deletions(-) delete mode 100644 Tusker/Screens/Status Action Account List/StatusActionAccountListTableViewController.swift create mode 100644 Tusker/Screens/Status Action Account List/StatusActionAccountListViewController.swift create mode 100644 Tusker/Views/Account Cell/AccountCollectionViewCell.swift diff --git a/Tusker.xcodeproj/project.pbxproj b/Tusker.xcodeproj/project.pbxproj index 92f4d36c..3f887ca0 100644 --- a/Tusker.xcodeproj/project.pbxproj +++ b/Tusker.xcodeproj/project.pbxproj @@ -203,7 +203,6 @@ D6A3BC852321F6C100FD64D5 /* AccountListTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A3BC832321F6C100FD64D5 /* AccountListTableViewController.swift */; }; D6A3BC8A2321F79B00FD64D5 /* AccountTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A3BC882321F79B00FD64D5 /* AccountTableViewCell.swift */; }; D6A3BC8B2321F79B00FD64D5 /* AccountTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D6A3BC892321F79B00FD64D5 /* AccountTableViewCell.xib */; }; - D6A3BC8F2321FFB900FD64D5 /* StatusActionAccountListTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A3BC8D2321FFB900FD64D5 /* StatusActionAccountListTableViewController.swift */; }; D6A4DCCC2553667800D9DE31 /* FastAccountSwitcherViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A4DCCA2553667800D9DE31 /* FastAccountSwitcherViewController.swift */; }; D6A4DCCD2553667800D9DE31 /* FastAccountSwitcherViewController.xib in Resources */ = {isa = PBXBuildFile; fileRef = D6A4DCCB2553667800D9DE31 /* FastAccountSwitcherViewController.xib */; }; D6A4DCE525537C7A00D9DE31 /* FastSwitchingAccountView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A4DCE425537C7A00D9DE31 /* FastSwitchingAccountView.swift */; }; @@ -271,6 +270,8 @@ D6CA6A92249FAD8900AD45C1 /* AudioSessionHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6CA6A91249FAD8900AD45C1 /* AudioSessionHelper.swift */; }; D6CA6A94249FADE700AD45C1 /* GalleryPlayerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6CA6A93249FADE700AD45C1 /* GalleryPlayerViewController.swift */; }; D6D12B2F2925D66500D528E1 /* TimelineGapCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D12B2E2925D66500D528E1 /* TimelineGapCollectionViewCell.swift */; }; + D6D12B56292D57E800D528E1 /* AccountCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D12B55292D57E800D528E1 /* AccountCollectionViewCell.swift */; }; + D6D12B58292D5B2C00D528E1 /* StatusActionAccountListViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D12B57292D5B2C00D528E1 /* StatusActionAccountListViewController.swift */; }; D6D3F4C424FDB6B700EC4A6A /* View+ConditionalModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D3F4C324FDB6B700EC4A6A /* View+ConditionalModifier.swift */; }; D6D3FDE224F46A8D00FF50A5 /* ComposeUIState.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D3FDE124F46A8D00FF50A5 /* ComposeUIState.swift */; }; D6D4CC94250DB86A00FCCF8D /* ComposeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D4CC93250DB86A00FCCF8D /* ComposeTests.swift */; }; @@ -566,7 +567,6 @@ D6A3BC832321F6C100FD64D5 /* AccountListTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountListTableViewController.swift; sourceTree = ""; }; D6A3BC882321F79B00FD64D5 /* AccountTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountTableViewCell.swift; sourceTree = ""; }; D6A3BC892321F79B00FD64D5 /* AccountTableViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = AccountTableViewCell.xib; sourceTree = ""; }; - D6A3BC8D2321FFB900FD64D5 /* StatusActionAccountListTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusActionAccountListTableViewController.swift; sourceTree = ""; }; D6A4DCCA2553667800D9DE31 /* FastAccountSwitcherViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FastAccountSwitcherViewController.swift; sourceTree = ""; }; D6A4DCCB2553667800D9DE31 /* FastAccountSwitcherViewController.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = FastAccountSwitcherViewController.xib; sourceTree = ""; }; D6A4DCE425537C7A00D9DE31 /* FastSwitchingAccountView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FastSwitchingAccountView.swift; sourceTree = ""; }; @@ -634,6 +634,8 @@ D6CA6A91249FAD8900AD45C1 /* AudioSessionHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioSessionHelper.swift; sourceTree = ""; }; D6CA6A93249FADE700AD45C1 /* GalleryPlayerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GalleryPlayerViewController.swift; sourceTree = ""; }; D6D12B2E2925D66500D528E1 /* TimelineGapCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineGapCollectionViewCell.swift; sourceTree = ""; }; + D6D12B55292D57E800D528E1 /* AccountCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountCollectionViewCell.swift; sourceTree = ""; }; + D6D12B57292D5B2C00D528E1 /* StatusActionAccountListViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusActionAccountListViewController.swift; sourceTree = ""; }; D6D3F4C324FDB6B700EC4A6A /* View+ConditionalModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+ConditionalModifier.swift"; sourceTree = ""; }; D6D3FDE124F46A8D00FF50A5 /* ComposeUIState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeUIState.swift; sourceTree = ""; }; D6D4CC93250DB86A00FCCF8D /* ComposeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeTests.swift; sourceTree = ""; }; @@ -1191,6 +1193,7 @@ children = ( D6A3BC882321F79B00FD64D5 /* AccountTableViewCell.swift */, D6A3BC892321F79B00FD64D5 /* AccountTableViewCell.xib */, + D6D12B55292D57E800D528E1 /* AccountCollectionViewCell.swift */, ); path = "Account Cell"; sourceTree = ""; @@ -1198,7 +1201,7 @@ D6A3BC8C2321FF9B00FD64D5 /* Status Action Account List */ = { isa = PBXGroup; children = ( - D6A3BC8D2321FFB900FD64D5 /* StatusActionAccountListTableViewController.swift */, + D6D12B57292D5B2C00D528E1 /* StatusActionAccountListViewController.swift */, ); path = "Status Action Account List"; sourceTree = ""; @@ -1874,7 +1877,6 @@ D6531DEE246B81C9000F9538 /* GifvAttachmentView.swift in Sources */, D673ACCE2919E74200D6F8B0 /* MenuPicker.swift in Sources */, D6370B9C24421FF30092A7FF /* Tusker.xcdatamodeld in Sources */, - D6A3BC8F2321FFB900FD64D5 /* StatusActionAccountListTableViewController.swift in Sources */, D6AEBB4823216B1D00E5038B /* AccountActivity.swift in Sources */, 04ED00B121481ED800567C53 /* SteppedProgressView.swift in Sources */, D677284C24ECBE9100C732D3 /* ComposeAvatarImageView.swift in Sources */, @@ -1951,6 +1953,7 @@ D6C82B5625C5F3F20017F1E6 /* ExpandThreadTableViewCell.swift in Sources */, D686BBE324FBF8110068E6AA /* WrappedProgressView.swift in Sources */, D620483423D3801D008A63EF /* LinkTextView.swift in Sources */, + D6D12B58292D5B2C00D528E1 /* StatusActionAccountListViewController.swift in Sources */, D6412B0D24B0D4CF00F5412E /* ProfileHeaderView.swift in Sources */, D641C77F213DC78A004B4513 /* InlineTextAttachment.swift in Sources */, D627943523A5525100D38C68 /* StatusActivity.swift in Sources */, @@ -2033,6 +2036,7 @@ D6E426B325337C7000C02E1C /* CustomEmojiImageView.swift in Sources */, D6D4DDD0212518A000E1C4BB /* AppDelegate.swift in Sources */, D6412B0924B0291E00F5412E /* MyProfileViewController.swift in Sources */, + D6D12B56292D57E800D528E1 /* AccountCollectionViewCell.swift in Sources */, D6A6C10525B6138A00298D0F /* StatusTablePrefetching.swift in Sources */, D6C82B4125C5BB7E0017F1E6 /* ExploreViewController.swift in Sources */, D6B053A423BD2C8100A066FA /* AssetCollectionsListViewController.swift in Sources */, diff --git a/Tusker/Screens/Status Action Account List/StatusActionAccountListTableViewController.swift b/Tusker/Screens/Status Action Account List/StatusActionAccountListTableViewController.swift deleted file mode 100644 index aac80280..00000000 --- a/Tusker/Screens/Status Action Account List/StatusActionAccountListTableViewController.swift +++ /dev/null @@ -1,163 +0,0 @@ -// -// StatusActionAccountListTableViewController.swift -// Tusker -// -// Created by Shadowfacts on 9/5/19. -// Copyright © 2019 Shadowfacts. All rights reserved. -// - -import UIKit -import Pachyderm - -class StatusActionAccountListTableViewController: EnhancedTableViewController { - - private let statusCell = "statusCell" - private let accountCell = "accountCell" - - weak var mastodonController: MastodonController! - - let actionType: ActionType - let statusID: String - var statusState: StatusState - var accountIDs: [String]? { - didSet { - tableView.reloadData() - } - } - - /// If `true`, a warning will be shown below the account list describing that the total favs/reblogs may be innacurate. - var showInacurateCountWarning = false - - /** - Creates a new view controller showing the accounts that performed the given action on the given status. - - - Parameter actionType The action that this VC is for. - - Parameter statusID The ID of the status to show. - - Parameter accountIDs The accounts that will be shown. If `nil` is passed, a request will be performed to load the accounts. - - Parameter mastodonController The `MastodonController` instance this view controller uses. - */ - init(actionType: ActionType, statusID: String, statusState: StatusState, accountIDs: [String]?, mastodonController: MastodonController) { - self.mastodonController = mastodonController - - self.actionType = actionType - self.statusID = statusID - self.statusState = statusState - self.accountIDs = accountIDs - - super.init(style: .grouped) - - dragEnabled = true - - switch actionType { - case .favorite: - title = NSLocalizedString("Favorited By", comment: "status favorited by accounts list title") - case .reblog: - title = NSLocalizedString("Reblogged By", comment: "status reblogged by accounts list title") - } - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func viewDidLoad() { - super.viewDidLoad() - - tableView.register(UINib(nibName: "TimelineStatusTableViewCell", bundle: .main), forCellReuseIdentifier: statusCell) - tableView.register(UINib(nibName: "AccountTableViewCell", bundle: .main), forCellReuseIdentifier: accountCell) - - tableView.rowHeight = UITableView.automaticDimension - tableView.estimatedRowHeight = 66 // height of account cell, which will be the most common - - tableView.alwaysBounceVertical = true - - tableView.tableHeaderView = UIView(frame: CGRect(x: 0, y: 0, width: 0, height: CGFloat.leastNormalMagnitude)) - - if accountIDs == nil { - // account IDs haven't been set, so perform a request to load them - guard let status = mastodonController.persistentContainer.status(for: statusID) else { - fatalError("Missing cached status \(statusID)") - } - - tableView.tableFooterView = UIActivityIndicatorView(style: .large) - - let request = actionType == .favorite ? Status.getFavourites(status.id) : Status.getReblogs(status.id) - mastodonController.run(request) { (response) in - guard case let .success(accounts, _) = response else { fatalError() } - self.mastodonController.persistentContainer.addAll(accounts: accounts) { - DispatchQueue.main.async { - self.accountIDs = accounts.map { $0.id } - self.tableView.tableFooterView = nil - } - } - } - } - } - - // MARK: - Table view data source - - override func numberOfSections(in tableView: UITableView) -> Int { - return 2 - } - - override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - switch section { - case 0: // status - return 1 - case 1: // accounts - if let accountIDs = accountIDs { - return accountIDs.count - } else { - return 0 - } - default: - fatalError("Invalid section \(section)") - } - } - - override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - switch indexPath.section { - case 0: - guard let cell = tableView.dequeueReusableCell(withIdentifier: statusCell, for: indexPath) as? TimelineStatusTableViewCell else { fatalError() } - cell.delegate = self - cell.updateUI(statusID: statusID, state: statusState) - return cell - case 1: - guard let accountIDs = accountIDs, - let cell = tableView.dequeueReusableCell(withIdentifier: accountCell, for: indexPath) as? AccountTableViewCell else { fatalError() } - cell.delegate = self - cell.updateUI(accountID: accountIDs[indexPath.row]) - return cell - default: - fatalError("Invalid section \(indexPath.section)") - } - } - - override func tableView(_ tableView: UITableView, titleForFooterInSection section: Int) -> String? { - guard section == 1, showInacurateCountWarning else { return nil } - return NSLocalizedString("Favorite and reblog counts for posts originating from instances other than your own may not be accurate.", comment: "shown on lists of status total actions") - } - - enum ActionType { - case favorite, reblog - } - -} - -extension StatusActionAccountListTableViewController: TuskerNavigationDelegate { - var apiController: MastodonController! { mastodonController } -} - -extension StatusActionAccountListTableViewController: ToastableViewController { -} - -extension StatusActionAccountListTableViewController: MenuActionProvider { -} - -extension StatusActionAccountListTableViewController: StatusTableViewCellDelegate { - func statusCellCollapsedStateChanged(_ cell: BaseStatusTableViewCell) { - // causes the table view to recalculate the cell heights - tableView.beginUpdates() - tableView.endUpdates() - } -} diff --git a/Tusker/Screens/Status Action Account List/StatusActionAccountListViewController.swift b/Tusker/Screens/Status Action Account List/StatusActionAccountListViewController.swift new file mode 100644 index 00000000..bce7dc9c --- /dev/null +++ b/Tusker/Screens/Status Action Account List/StatusActionAccountListViewController.swift @@ -0,0 +1,266 @@ +// +// StatusActionAccountListViewController.swift +// Tusker +// +// Created by Shadowfacts on 9/5/19. +// Copyright © 2019 Shadowfacts. All rights reserved. +// + +import UIKit +import Pachyderm + +class StatusActionAccountListViewController: UIViewController { + + private let mastodonController: MastodonController + private let actionType: ActionType + private let statusID: String + private let statusState: StatusState + private var accountIDs: [String]? + + /// If `true`, a warning will be shown below the account list describing that the total favs/reblogs may be innacurate. + var showInacurateCountWarning = false + + private var collectionView: UICollectionView { + view as! UICollectionView + } + private var dataSource: UICollectionViewDiffableDataSource! + + /** + Creates a new view controller showing the accounts that performed the given action on the given status. + + - Parameter actionType The action that this VC is for. + - Parameter statusID The ID of the status to show. + - Parameter accountIDs The accounts that will be shown. If `nil` is passed, a request will be performed to load the accounts. + - Parameter mastodonController The `MastodonController` instance this view controller uses. + */ + init(actionType: ActionType, statusID: String, statusState: StatusState, accountIDs: [String]?, mastodonController: MastodonController) { + self.mastodonController = mastodonController + self.actionType = actionType + self.statusID = statusID + self.statusState = statusState + self.accountIDs = accountIDs + + super.init(nibName: nil, bundle: nil) + + switch actionType { + case .favorite: + title = NSLocalizedString("Favorited By", comment: "status favorited by accounts list title") + case .reblog: + title = NSLocalizedString("Reblogged By", comment: "status reblogged by accounts list title") + } + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func loadView() { + let layout = UICollectionViewCompositionalLayout { [unowned self] sectionIndex, environment in + switch dataSource.sectionIdentifier(for: sectionIndex)! { + case .status: + var config = UICollectionLayoutListConfiguration(appearance: .grouped) + config.footerMode = self.showInacurateCountWarning ? .supplementary : .none + config.leadingSwipeActionsConfigurationProvider = { [unowned self] in + (collectionView.cellForItem(at: $0) as? TimelineStatusCollectionViewCell)?.leadingSwipeActions() + } + config.trailingSwipeActionsConfigurationProvider = { [unowned self] in + (collectionView.cellForItem(at: $0) as? TimelineStatusCollectionViewCell)?.trailingSwipeActions() + } + return NSCollectionLayoutSection.list(using: config, layoutEnvironment: environment) + case .accounts: + return NSCollectionLayoutSection.list(using: .init(appearance: .grouped), layoutEnvironment: environment) + } + } + view = UICollectionView(frame: .zero, collectionViewLayout: layout) + collectionView.delegate = self + collectionView.dragDelegate = self + dataSource = createDataSource() + } + + private func createDataSource() -> UICollectionViewDiffableDataSource { + let statusCell = UICollectionView.CellRegistration { [unowned self] cell, indexPath, _ in + cell.delegate = self + cell.updateUI(statusID: self.statusID, state: self.statusState) + } + let accountCell = UICollectionView.CellRegistration { [unowned self] cell, indexPath, item in + cell.delegate = self + cell.updateUI(accountID: item) + } + let dataSource = UICollectionViewDiffableDataSource(collectionView: collectionView) { collectionView, indexPath, itemIdentifier in + switch itemIdentifier { + case .status: + return collectionView.dequeueConfiguredReusableCell(using: statusCell, for: indexPath, item: ()) + case .account(let id): + return collectionView.dequeueConfiguredReusableCell(using: accountCell, for: indexPath, item: id) + } + } + let sectionHeaderCell = UICollectionView.SupplementaryRegistration(elementKind: UICollectionView.elementKindSectionFooter) { (headerView, collectionView, indexPath) in + var config = headerView.defaultContentConfiguration() + config.text = NSLocalizedString("Favorite and reblog counts for posts originating from instances other than your own may not be accurate.", comment: "shown on lists of status total actions") + headerView.contentConfiguration = config + } + dataSource.supplementaryViewProvider = { collectionView, elementKind, indexPath in + return collectionView.dequeueConfiguredReusableSupplementary(using: sectionHeaderCell, for: indexPath) + } + return dataSource + } + + override func viewDidLoad() { + super.viewDidLoad() + + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([.status, .accounts]) + snapshot.appendItems([.status], toSection: .status) + if let accountIDs { + snapshot.appendItems(accountIDs.map { .account($0) }, toSection: .accounts) + } + dataSource.apply(snapshot, animatingDifferences: false) + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + if accountIDs == nil { + Task { + await loadAccounts() + } + } + } + + private func loadAccounts() async { + let request: Request<[Account]> + switch actionType { + case .favorite: + request = Status.getFavourites(statusID) + case .reblog: + request = Status.getReblogs(statusID) + } + do { + // TODO: pagination + let (accounts, _) = try await mastodonController.run(request) + + await withCheckedContinuation { continuation in + mastodonController.persistentContainer.addAll(accounts: accounts) { + continuation.resume() + } + } + + accountIDs = accounts.map(\.id) + + var snapshot = dataSource.snapshot() + snapshot.appendItems(accounts.map { .account($0.id) }, toSection: .accounts) + dataSource.apply(snapshot, animatingDifferences: true) {} + + } catch { + let config = ToastConfiguration(from: error, with: "Error Loading Accounts", in: self) { toast in + toast.dismissToast(animated: true) + await self.loadAccounts() + } + self.showToast(configuration: config, animated: true) + } + } +} + +extension StatusActionAccountListViewController { + enum ActionType { + case favorite, reblog + } +} + +extension StatusActionAccountListViewController { + enum Section { + case status + case accounts + } + enum Item: Hashable { + case status + case account(String) + } +} + +extension StatusActionAccountListViewController: UICollectionViewDelegate { + func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + switch dataSource.itemIdentifier(for: indexPath) { + case nil: + return + case .status: + selected(status: statusID, state: statusState.copy()) + case .account(let id): + selected(account: id) + } + } + + func collectionView(_ collectionView: UICollectionView, contextMenuConfigurationForItemAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? { + guard let item = dataSource.itemIdentifier(for: indexPath), + let cell = collectionView.cellForItem(at: indexPath) else { + return nil + } + switch item { + case .status: + return (cell as? TimelineStatusCollectionViewCell)?.contextMenuConfiguration() + case .account(let id): + return UIContextMenuConfiguration { + ProfileViewController(accountID: id, mastodonController: self.mastodonController) + } actionProvider: { _ in + UIMenu(children: self.actionsForProfile(accountID: id, sourceView: cell)) + } + } + } + + func collectionView(_ collectionView: UICollectionView, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) { + MenuPreviewHelper.willPerformPreviewAction(animator: animator, presenter: self) + } +} + +extension StatusActionAccountListViewController: UICollectionViewDragDelegate { + func collectionView(_ collectionView: UICollectionView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath) -> [UIDragItem] { + guard let currentAccountID = mastodonController.accountInfo?.id, + let item = dataSource.itemIdentifier(for: indexPath) else { + return [] + } + let provider: NSItemProvider + switch item { + case .status: + guard let status = mastodonController.persistentContainer.status(for: statusID) else { + return [] + } + provider = NSItemProvider(object: status.url! as NSURL) + let activity = UserActivityManager.showConversationActivity(mainStatusID: statusID, accountID: currentAccountID) + activity.displaysAuxiliaryScene = true + provider.registerObject(activity, visibility: .all) + case .account(let id): + guard let account = mastodonController.persistentContainer.account(for: id) else { + return [] + } + provider = NSItemProvider(object: account.url as NSURL) + let activity = UserActivityManager.showProfileActivity(id: account.id, accountID: currentAccountID) + activity.displaysAuxiliaryScene = true + provider.registerObject(activity, visibility: .all) + } + return [UIDragItem(itemProvider: provider)] + } +} + +extension StatusActionAccountListViewController: TuskerNavigationDelegate { + var apiController: MastodonController! { mastodonController } +} + +extension StatusActionAccountListViewController: MenuActionProvider { +} + +extension StatusActionAccountListViewController: StatusCollectionViewCellDelegate { + func statusCellNeedsReconfigure(_ cell: StatusCollectionViewCell, animated: Bool, completion: (() -> Void)?) { + if let indexPath = collectionView.indexPath(for: cell) { + var snapshot = dataSource.snapshot() + snapshot.reconfigureItems([dataSource.itemIdentifier(for: indexPath)!]) + dataSource.apply(snapshot, animatingDifferences: animated, completion: completion) + } + } +} + +extension StatusActionAccountListViewController: StatusBarTappableViewController { + func handleStatusBarTapped(xPosition: CGFloat) -> StatusBarTapActionResult { + collectionView.scrollToTop() + return .stop + } +} diff --git a/Tusker/TuskerNavigationDelegate.swift b/Tusker/TuskerNavigationDelegate.swift index 64ee7c2c..7789a259 100644 --- a/Tusker/TuskerNavigationDelegate.swift +++ b/Tusker/TuskerNavigationDelegate.swift @@ -183,8 +183,8 @@ extension TuskerNavigationDelegate { show(vc, sender: self) } - func statusActionAccountList(action: StatusActionAccountListTableViewController.ActionType, statusID: String, statusState state: StatusState, accountIDs: [String]?) -> StatusActionAccountListTableViewController { - return StatusActionAccountListTableViewController(actionType: action, statusID: statusID, statusState: state, accountIDs: accountIDs, mastodonController: apiController) + func statusActionAccountList(action: StatusActionAccountListViewController.ActionType, statusID: String, statusState state: StatusState, accountIDs: [String]?) -> StatusActionAccountListViewController { + return StatusActionAccountListViewController(actionType: action, statusID: statusID, statusState: state, accountIDs: accountIDs, mastodonController: apiController) } } diff --git a/Tusker/Views/Account Cell/AccountCollectionViewCell.swift b/Tusker/Views/Account Cell/AccountCollectionViewCell.swift new file mode 100644 index 00000000..b200dccf --- /dev/null +++ b/Tusker/Views/Account Cell/AccountCollectionViewCell.swift @@ -0,0 +1,148 @@ +// +// AccountCollectionViewCell.swift +// Tusker +// +// Created by Shadowfacts on 11/22/22. +// Copyright © 2022 Shadowfacts. All rights reserved. +// + +import UIKit +import SwiftSoup + +class AccountCollectionViewCell: UICollectionViewListCell { + + private lazy var hStack = UIStackView(arrangedSubviews: [ + avatarImageView, + vStack, + ]).configure { + $0.axis = .horizontal + $0.spacing = 8 + $0.alignment = .leading + } + + private let avatarImageView = CachedImageView(cache: .avatars).configure { + $0.layer.masksToBounds = true + NSLayoutConstraint.activate([ + $0.widthAnchor.constraint(equalToConstant: 50), + $0.heightAnchor.constraint(equalToConstant: 50), + ]) + } + + private lazy var vStack = UIStackView(arrangedSubviews: [ + displayNameLabel, + usernameLabel, + noteLabel, + ]).configure { + $0.axis = .vertical + $0.spacing = 4 + $0.alignment = .leading + } + + private let displayNameLabel = EmojiLabel().configure { + $0.font = .preferredFont(forTextStyle: .body) + $0.adjustsFontSizeToFitWidth = true + $0.adjustsFontForContentSizeCategory = true + } + + private let usernameLabel = UILabel().configure { + $0.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 15, weight: .light)) + $0.textColor = .secondaryLabel + $0.adjustsFontSizeToFitWidth = true + $0.adjustsFontForContentSizeCategory = true + } + + private let noteLabel = EmojiLabel().configure { + $0.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 15)) + $0.numberOfLines = 2 + } + + weak var delegate: (TuskerNavigationDelegate & MenuActionProvider)? + var mastodonController: MastodonController! { delegate?.apiController } + + private var accountID: String? + private var isGrayscale = false + + override init(frame: CGRect) { + super.init(frame: .zero) + + hStack.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(hStack) + NSLayoutConstraint.activate([ + hStack.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 8), + hStack.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -8), + hStack.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 8), + hStack.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -8), + ]) + + NotificationCenter.default.addObserver(self, selector: #selector(preferencesChanged), name: .preferencesChanged, object: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: Accessibility + + override var isAccessibilityElement: Bool { + get { true } + set {} + } + + override var accessibilityAttributedLabel: NSAttributedString? { + get { + guard let accountID, + let account = mastodonController.persistentContainer.account(for: accountID) else { + return nil + } + var str = AttributedString(account.displayOrUserName) + str += ", @" + str += AttributedString(account.acct) + return NSAttributedString(str) + } + set {} + } + + override func accessibilityActivate() -> Bool { + guard let accountID else { + return false + } + delegate?.selected(account: accountID) + return true + } + + // MARK: Configure UI + + func updateUI(accountID: String) { + guard let account = mastodonController.persistentContainer.account(for: accountID) else { + fatalError() + } + self.accountID = accountID + + avatarImageView.update(for: account.avatar) + usernameLabel.text = "@\(account.acct)" + updateUIForPreferences(account: account) + } + + private func updateUIForPreferences(account: AccountMO) { + avatarImageView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadiusFraction * 50 + isGrayscale = Preferences.shared.grayscaleImages + if Preferences.shared.hideCustomEmojiInUsernames { + displayNameLabel.text = account.displayNameWithoutCustomEmoji + } else { + displayNameLabel.text = account.displayOrUserName + displayNameLabel.setEmojis(account.emojis, identifier: account.id) + } + + let doc = try! SwiftSoup.parseBodyFragment(account.note) + noteLabel.text = try! doc.text() + noteLabel.setEmojis(account.emojis, identifier: account.id) + } + + @objc private func preferencesChanged() { + if let accountID, + let account = mastodonController?.persistentContainer.account(for: accountID) { + updateUIForPreferences(account: account) + } + } + +} diff --git a/Tusker/Views/CachedImageView.swift b/Tusker/Views/CachedImageView.swift index 8183a0e8..b98378a5 100644 --- a/Tusker/Views/CachedImageView.swift +++ b/Tusker/Views/CachedImageView.swift @@ -56,6 +56,7 @@ class CachedImageView: UIImageView { guard let transformedImage = ImageGrayscalifier.convertIfNecessary(url: url, image: image) else { return } + try Task.checkCancellation() self.image = transformedImage } } diff --git a/Tusker/Views/Notifications/ActionNotificationGroupTableViewCell.swift b/Tusker/Views/Notifications/ActionNotificationGroupTableViewCell.swift index d8ac6193..03f4f022 100644 --- a/Tusker/Views/Notifications/ActionNotificationGroupTableViewCell.swift +++ b/Tusker/Views/Notifications/ActionNotificationGroupTableViewCell.swift @@ -218,7 +218,7 @@ extension ActionNotificationGroupTableViewCell: SelectableTableViewCell { guard let delegate = delegate else { return } let notifications = group.notifications let accountIDs = notifications.map { $0.account.id } - let action: StatusActionAccountListTableViewController.ActionType + let action: StatusActionAccountListViewController.ActionType switch notifications.first!.kind { case .favourite: action = .favorite @@ -228,6 +228,7 @@ extension ActionNotificationGroupTableViewCell: SelectableTableViewCell { fatalError() } let vc = delegate.statusActionAccountList(action: action, statusID: statusID, statusState: .unknown, accountIDs: accountIDs) + vc.showInacurateCountWarning = false delegate.show(vc) } } @@ -235,9 +236,12 @@ extension ActionNotificationGroupTableViewCell: SelectableTableViewCell { extension ActionNotificationGroupTableViewCell: MenuPreviewProvider { func getPreviewProviders(for location: CGPoint, sourceViewController: UIViewController) -> PreviewProviders? { return (content: { + guard let delegate = self.delegate else { + return nil + } let notifications = self.group.notifications let accountIDs = notifications.map { $0.account.id } - let action: StatusActionAccountListTableViewController.ActionType + let action: StatusActionAccountListViewController.ActionType switch notifications.first!.kind { case .favourite: action = .favorite @@ -246,7 +250,9 @@ extension ActionNotificationGroupTableViewCell: MenuPreviewProvider { default: fatalError() } - return self.delegate?.statusActionAccountList(action: action, statusID: self.statusID, statusState: .unknown, accountIDs: accountIDs) + let vc = delegate.statusActionAccountList(action: action, statusID: self.statusID, statusState: .unknown, accountIDs: accountIDs) + vc.showInacurateCountWarning = false + return vc }, actions: { return [] })