// // ProfileStatusesViewController.swift // Tusker // // Created by Shadowfacts on 10/6/22. // Copyright © 2022 Shadowfacts. All rights reserved. // import UIKit import Pachyderm import Combine class ProfileStatusesViewController: UIViewController, TimelineLikeCollectionViewController, CollectionViewController { weak var owner: ProfileViewController? let mastodonController: MastodonController let filterer: Filterer private(set) var accountID: String! let kind: Kind var initialHeaderMode: HeaderMode? weak var profileHeaderDelegate: ProfileHeaderViewDelegate? private(set) var controller: TimelineLikeController! let confirmLoadMore = PassthroughSubject() private var newer: RequestRange? private var older: RequestRange? private var cancellables = Set() var collectionView: UICollectionView! { view as? UICollectionView } private(set) var dataSource: UICollectionViewDiffableDataSource! private(set) var headerCell: ProfileHeaderCollectionViewCell? private(set) var state: State = .unloaded init(accountID: String?, kind: Kind, owner: ProfileViewController) { self.accountID = accountID self.kind = kind self.owner = owner self.mastodonController = owner.mastodonController self.filterer = Filterer(mastodonController: mastodonController, context: .account) self.filterer.htmlConverter.font = TimelineStatusCollectionViewCell.contentFont self.filterer.htmlConverter.paragraphStyle = TimelineStatusCollectionViewCell.contentParagraphStyle super.init(nibName: nil, bundle: nil) self.controller = TimelineLikeController(delegate: self) addKeyCommand(MenuController.refreshCommand(discoverabilityTitle: "Refresh Profile")) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func loadView() { var config = UICollectionLayoutListConfiguration(appearance: .plain) config.backgroundColor = .appBackground config.leadingSwipeActionsConfigurationProvider = { [unowned self] in (collectionView.cellForItem(at: $0) as? TimelineStatusCollectionViewCell)?.leadingSwipeActions() } config.trailingSwipeActionsConfigurationProvider = { [unowned self] in (collectionView.cellForItem(at: $0) as? TimelineStatusCollectionViewCell)?.trailingSwipeActions() } 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 } else if case .status(id: _, collapseState: _, filterState: let filterState, pinned: _) = item, filterer.isKnownHide(state: filterState) { config.topSeparatorVisibility = .hidden config.bottomSeparatorVisibility = .hidden } else if case .status(_, _, _, _) = item { config.topSeparatorInsets = TimelineStatusCollectionViewCell.separatorInsets config.bottomSeparatorInsets = TimelineStatusCollectionViewCell.separatorInsets } return config } let layout = UICollectionViewCompositionalLayout { [unowned self] sectionIndex, environment in if case .header = dataSource.sectionIdentifier(for: sectionIndex) { var config = UICollectionLayoutListConfiguration(appearance: .plain) config.backgroundColor = .appBackground return .list(using: config, layoutEnvironment: environment) } else { let section = NSCollectionLayoutSection.list(using: config, layoutEnvironment: environment) if UIDevice.current.userInterfaceIdiom == .pad || UIDevice.current.userInterfaceIdiom == .mac { section.contentInsetsReference = .readableContent } return section } } view = UICollectionView(frame: .zero, collectionViewLayout: layout) collectionView.delegate = self collectionView.dragDelegate = self collectionView.allowsFocus = true registerTimelineLikeCells() dataSource = createDataSource() #if !targetEnvironment(macCatalyst) collectionView.refreshControl = UIRefreshControl() collectionView.refreshControl!.addTarget(self, action: #selector(refresh), for: .valueChanged) #endif } override func viewDidLoad() { super.viewDidLoad() mastodonController.persistentContainer.accountSubject .receive(on: DispatchQueue.main) .filter { [unowned self] in $0 == self.accountID } .sink { [unowned self] id in switch state { case .unloaded: Task { [self] in await self.load() } case .loaded, .setupInitialSnapshot: var snapshot = self.dataSource.snapshot() snapshot.reconfigureItems([.header(id)]) self.dataSource.apply(snapshot, animatingDifferences: true) } } .store(in: &cancellables) mastodonController.persistentContainer.relationshipSubject .receive(on: DispatchQueue.main) .filter { [unowned self] in $0 == self.accountID } .sink { [unowned self] id in switch state { case .unloaded: break case .loaded, .setupInitialSnapshot: var snapshot = self.dataSource.snapshot() snapshot.reconfigureItems([.header(id)]) self.dataSource.apply(snapshot, animatingDifferences: true) } } .store(in: &cancellables) filterer.filtersChanged = { [unowned self] actionsChanged in self.reapplyFilters(actionsChanged: actionsChanged) } NotificationCenter.default.addObserver(self, selector: #selector(handleStatusDeleted), name: .statusDeleted, object: nil) } private func createDataSource() -> UICollectionViewDiffableDataSource { collectionView.register(ProfileHeaderCollectionViewCell.self, forCellWithReuseIdentifier: "headerCell") let statusCell = UICollectionView.CellRegistration { [unowned self] cell, indexPath, item in cell.delegate = self cell.showPinned = item.4 cell.updateUI(statusID: item.0, state: item.1, filterResult: item.2, precomputedContent: item.3) } let zeroHeightCell = UICollectionView.CellRegistration { _, _, _ in } let noContentCell = UICollectionView.CellRegistration { [unowned self] cell, _, item in cell.delegate = self cell.updateUI(accountURL: item) } return UICollectionViewDiffableDataSource(collectionView: collectionView) { [unowned self] collectionView, indexPath, itemIdentifier in switch itemIdentifier { case .header(let id): if let headerCell = self.headerCell { headerCell.view?.updateUI(for: id) return headerCell } else { let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "headerCell", for: indexPath) as! ProfileHeaderCollectionViewCell switch self.initialHeaderMode { case nil: fatalError("missing initialHeaderMode") case .createView: let view = ProfileHeaderView.create() view.delegate = self.profileHeaderDelegate view.updateUI(for: id) view.pagesSegmentedControl.setSelectedOption(self.owner!.currentPage, animated: false) cell.addHeader(view) case .useExistingView(let view): view.updateUI(for: id) cell.addHeader(view) case .placeholder(height: let height): _ = cell.addConstraint(height: height) } self.headerCell = cell return cell } case .status(id: let id, collapseState: let collapseState, filterState: let filterState, pinned: let pinned): let (result, precomputedContent) = filterResult(state: filterState, statusID: id) switch result { case .allow, .warn(_): return collectionView.dequeueConfiguredReusableCell(using: statusCell, for: indexPath, item: (id, collapseState, result, precomputedContent, pinned)) case .hide: return collectionView.dequeueConfiguredReusableCell(using: zeroHeightCell, for: indexPath, item: ()) } case .loadingIndicator: return loadingIndicatorCell(for: indexPath) case .confirmLoadMore: return confirmLoadMoreCell(for: indexPath) case .noContent(let accountURL): return collectionView.dequeueConfiguredReusableCell(using: noContentCell, for: indexPath, item: accountURL) } } } override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) clearSelectionOnAppear(animated: animated) Task { if case .notLoadedInitial = controller.state { await load() } } } func setAccountID(_ id: String) { self.accountID = id // TODO: maybe this function should be async? Task { await load() } } private func load() async { guard isViewLoaded, let accountID, state == .unloaded, let account = mastodonController.persistentContainer.account(for: accountID) else { return } var snapshot = NSDiffableDataSourceSnapshot() snapshot.appendSections([.header, .pinned, .statuses]) snapshot.appendItems([.header(accountID)], toSection: .header) await apply(snapshot, animatingDifferences: false) state = .setupInitialSnapshot Task { if let (all, _) = try? await mastodonController.run(Client.getRelationships(accounts: [accountID])), let relationship = all.first { self.mastodonController.persistentContainer.addOrUpdate(relationship: relationship) } } await controller.loadInitial() await tryLoadPinned() var newSnapshot = dataSource.snapshot() if newSnapshot.numberOfItems(inSection: .pinned) == 0, newSnapshot.numberOfItems(inSection: .statuses) == 0, account.url.host != mastodonController.instanceURL.host { newSnapshot.appendItems([.noContent(accountURL: account.url)], toSection: .pinned) await apply(newSnapshot, animatingDifferences: true) } state = .loaded // remove any content inset that was added when switching pages to this VC collectionView.contentInset = .zero } private func tryLoadPinned() async { do { try await loadPinned() } catch { let config = ToastConfiguration(from: error, with: "Loading Pinned", in: self) { toast in toast.dismissToast(animated: true) await self.tryLoadPinned() } self.showToast(configuration: config, animated: true) } } private func loadPinned() async throws { guard case .statuses = kind, mastodonController.instanceFeatures.profilePinnedStatuses else { return } let request = Account.getStatuses(accountID, range: .default, onlyMedia: false, pinned: true, excludeReplies: false) let (statuses, _) = try await mastodonController.run(request) await withCheckedContinuation { continuation in mastodonController.persistentContainer.addAll(statuses: statuses) { continuation.resume() } } var snapshot = dataSource.snapshot() let existingPinned = snapshot.itemIdentifiers(inSection: .pinned) let items = statuses.map { let item = Item.status(id: $0.id, collapseState: .unknown, filterState: .unknown, pinned: true) // try to keep the existing status state if let existing = existingPinned.first(where: { $0 == item }) { return existing } else { return item } } snapshot.deleteItems(snapshot.itemIdentifiers(inSection: .pinned)) snapshot.appendItems(items, toSection: .pinned) await apply(snapshot, animatingDifferences: true) } private func filterResult(state: FilterState, statusID: String) -> (Filterer.Result, NSAttributedString?) { let status = { let status = self.mastodonController.persistentContainer.status(for: statusID)! // if the status is a reblog of another one, filter based on that one if let reblogged = status.reblog { return (reblogged, true) } else { return (status, false) } } return filterer.resolve(state: state, status: status) } private func reapplyFilters(actionsChanged: Bool) { let visible = collectionView.indexPathsForVisibleItems let items = visible .compactMap { dataSource.itemIdentifier(for: $0) } .filter { if case .status(_, _, _, _) = $0 { return true } else { return false } } guard !items.isEmpty else { return } var snapshot = dataSource.snapshot() if actionsChanged { snapshot.reloadItems(items) } else { snapshot.reconfigureItems(items) } dataSource.apply(snapshot) } @objc func refresh() { guard case .loaded = state else { #if !targetEnvironment(macCatalyst) collectionView.refreshControl?.endRefreshing() #endif return } Task { // TODO: coalesce these data source updates // TODO: refresh profile await controller.loadNewer() await tryLoadPinned() #if !targetEnvironment(macCatalyst) collectionView.refreshControl?.endRefreshing() #endif } } @objc private func handleStatusDeleted(_ notification: Foundation.Notification) { guard let userInfo = notification.userInfo, let accountID = mastodonController.accountInfo?.id, userInfo["accountID"] as? String == accountID, let statusIDs = userInfo["statusIDs"] as? [String] else { return } var snapshot = self.dataSource.snapshot() let toDelete = statusIDs .flatMap { id in // need to delete from both pinned and non-pinned sections [ Item.status(id: id, collapseState: .unknown, filterState: .unknown, pinned: false), Item.status(id: id, collapseState: .unknown, filterState: .unknown, pinned: true), ] } .filter { item in snapshot.itemIdentifiers.contains(item) } if !toDelete.isEmpty { snapshot.deleteItems(toDelete) self.dataSource.apply(snapshot, animatingDifferences: true) } } } extension ProfileStatusesViewController { enum State { case unloaded case setupInitialSnapshot case loaded } } extension ProfileStatusesViewController { enum Kind { case statuses, withReplies, onlyMedia } enum HeaderMode { case createView, useExistingView(ProfileHeaderView), placeholder(height: CGFloat) } } extension ProfileStatusesViewController { enum Section: TimelineLikeCollectionViewSection { case header case pinned case statuses case footer static var entries: Self { .statuses } } enum Item: TimelineLikeCollectionViewItem { typealias TimelineItem = String case header(String) // the status item must contain the pinned state, since a status can appear in both the pinned and regular sections simultaneously case status(id: String, collapseState: CollapseState, filterState: FilterState, pinned: Bool) case loadingIndicator case confirmLoadMore case noContent(accountURL: URL) static func fromTimelineItem(_ item: String) -> Self { return .status(id: item, collapseState: .unknown, filterState: .unknown, pinned: false) } static func ==(lhs: Item, rhs: Item) -> Bool { switch (lhs, rhs) { case let (.header(a), .header(b)): return a == b case let (.status(id: a, _, _, pinned: ap), .status(id: b, _, _, pinned: bp)): return a == b && ap == bp case (.loadingIndicator, .loadingIndicator): return true case (.confirmLoadMore, .confirmLoadMore): return true case (.noContent(let a), .noContent(let b)): return a == b default: return false } } func hash(into hasher: inout Hasher) { switch self { case .header(let id): hasher.combine(0) hasher.combine(id) case .status(id: let id, _, _, pinned: let pinned): hasher.combine(1) hasher.combine(id) hasher.combine(pinned) case .loadingIndicator: hasher.combine(2) case .confirmLoadMore: hasher.combine(3) case .noContent(let accountURL): hasher.combine(4) hasher.combine(accountURL) } } var hideSeparators: Bool { switch self { case .loadingIndicator, .confirmLoadMore, .noContent(_): return true default: return false } } var isSelectable: Bool { switch self { case .status(_, _, _, _): return true default: return false } } } } extension ProfileStatusesViewController: TimelineLikeControllerDelegate { typealias TimelineItem = String // status ID private func request(for range: RequestRange = .default) -> Request<[Status]> { switch kind { case .statuses: return Account.getStatuses(accountID, range: range, onlyMedia: false, pinned: false, excludeReplies: true) case .withReplies: return Account.getStatuses(accountID, range: range, onlyMedia: false, pinned: false, excludeReplies: false) case .onlyMedia: return Account.getStatuses(accountID, range: range, onlyMedia: true, pinned: false, excludeReplies: false) } } func loadInitial() async throws -> [String] { let request = request() let (statuses, _) = try await mastodonController.run(request) if !statuses.isEmpty { newer = .after(id: statuses.first!.id, count: nil) older = .before(id: statuses.last!.id, count: nil) } return await withCheckedContinuation { continuation in mastodonController.persistentContainer.addAll(statuses: statuses) { continuation.resume(returning: statuses.map(\.id)) } } } func loadNewer() async throws -> [String] { guard let newer else { throw Error.noNewer } let request = request(for: newer) let (statuses, _) = try await mastodonController.run(request) guard !statuses.isEmpty else { throw Error.allCaughtUp } self.newer = .after(id: statuses.first!.id, count: nil) return await withCheckedContinuation { continuation in mastodonController.persistentContainer.addAll(statuses: statuses) { continuation.resume(returning: statuses.map(\.id)) } } } func loadOlder() async throws -> [String] { guard let older else { throw Error.noOlder } let request = request(for: older) let (statuses, _) = try await mastodonController.run(request) guard !statuses.isEmpty else { return [] } self.older = .before(id: statuses.last!.id, count: nil) return await withCheckedContinuation { continuation in mastodonController.persistentContainer.addAll(statuses: statuses) { continuation.resume(returning: statuses.map(\.id)) } } } enum Error: TimelineLikeCollectionViewError { case noNewer case noOlder case allCaughtUp } } extension ProfileStatusesViewController: UICollectionViewDelegate { func collectionView(_ collectionView: UICollectionView, willDisplay cell: UICollectionViewCell, forItemAt indexPath: IndexPath) { guard case .statuses = dataSource.sectionIdentifier(for: indexPath.section) else { return } let itemsInSection = collectionView.numberOfItems(inSection: indexPath.section) if indexPath.row == itemsInSection - 1 { Task { await controller.loadOlder() } } } func collectionView(_ collectionView: UICollectionView, shouldSelectItemAt indexPath: IndexPath) -> Bool { return dataSource.itemIdentifier(for: indexPath)?.isSelectable ?? false } func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { guard let item = dataSource.itemIdentifier(for: indexPath), case .status(id: let id, collapseState: let collapseState, filterState: let filterState, pinned: _) = item else { return } if filterState.isWarning { filterer.setResult(.allow, for: filterState) collectionView.deselectItem(at: indexPath, animated: true) var snapshot = dataSource.snapshot() snapshot.reconfigureItems([item]) dataSource.apply(snapshot, animatingDifferences: true) } else { let status = mastodonController.persistentContainer.status(for: id)! selected(status: status.reblog?.id ?? id, state: collapseState.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) } func collectionView(_ collectionView: UICollectionView, canFocusItemAt indexPath: IndexPath) -> Bool { switch dataSource.itemIdentifier(for: indexPath) { case .header(_), .loadingIndicator: return false default: return true } } } extension ProfileStatusesViewController: UICollectionViewDragDelegate { func collectionView(_ collectionView: UICollectionView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath) -> [UIDragItem] { (collectionView.cellForItem(at: indexPath) as? TimelineStatusCollectionViewCell)?.dragItemsForBeginning(session: session) ?? [] } } extension ProfileStatusesViewController: TuskerNavigationDelegate { var apiController: MastodonController! { mastodonController } } extension ProfileStatusesViewController: MenuActionProvider { } extension ProfileStatusesViewController: 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) } } func statusCellShowFiltered(_ cell: StatusCollectionViewCell) { if let indexPath = collectionView.indexPath(for: cell), let item = dataSource.itemIdentifier(for: indexPath), case .status(id: _, collapseState: _, filterState: let filterState, pinned: _) = item { filterer.setResult(.allow, for: filterState) var snapshot = dataSource.snapshot() snapshot.reconfigureItems([item]) dataSource.apply(snapshot, animatingDifferences: true) } } } extension ProfileStatusesViewController: TabBarScrollableViewController { func tabBarScrollToTop() { collectionView.scrollToTop() } } extension ProfileStatusesViewController: StatusBarTappableViewController { func handleStatusBarTapped(xPosition: CGFloat) -> StatusBarTapActionResult { collectionView.scrollToTop() return .stop } }