// // TimelineViewController.swift // Tusker // // Created by Shadowfacts on 9/20/22. // Copyright © 2022 Shadowfacts. All rights reserved. // import UIKit import Pachyderm import Combine class TimelineViewController: UIViewController, TimelineLikeCollectionViewController, CollectionViewController, RefreshableViewController { let timeline: Timeline weak var mastodonController: MastodonController! let filterer: Filterer private(set) var controller: TimelineLikeController! let confirmLoadMore = PassthroughSubject() // stored separately because i don't want to query the snapshot every time the user scrolls private var isShowingTimelineDescription = false private(set) var collectionView: UICollectionView! private(set) var dataSource: UICollectionViewDiffableDataSource! private var contentOffsetObservation: NSKeyValueObservation? private var activityToRestore: NSUserActivity? init(for timeline: Timeline, mastodonController: MastodonController!) { self.timeline = timeline self.mastodonController = mastodonController let filterContext: FilterV1.Context switch timeline { case .home, .list(id: _): filterContext = .home default: filterContext = .public } self.filterer = Filterer(mastodonController: mastodonController, context: filterContext) self.filterer.htmlConverter.font = TimelineStatusCollectionViewCell.contentFont self.filterer.htmlConverter.paragraphStyle = TimelineStatusCollectionViewCell.contentParagraphStyle super.init(nibName: nil, bundle: nil) self.controller = TimelineLikeController(delegate: self) self.navigationItem.title = timeline.title addKeyCommand(MenuController.refreshCommand(discoverabilityTitle: "Refresh Timeline")) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func viewDidLoad() { super.viewDidLoad() var config = UICollectionLayoutListConfiguration(appearance: .plain) 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: let id, collapseState: _, filterState: let filterState) = item, case (.hide, _) = filterResult(state: filterState, statusID: id) { // this runs after the cell is setup, so the filter state is already known and this check is cheap config.topSeparatorVisibility = .hidden config.bottomSeparatorVisibility = .hidden } else { config.topSeparatorInsets = TimelineStatusCollectionViewCell.separatorInsets config.bottomSeparatorInsets = TimelineStatusCollectionViewCell.separatorInsets } return config } let layout = UICollectionViewCompositionalLayout.list(using: config) collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout) collectionView.delegate = self collectionView.dragDelegate = self collectionView.translatesAutoresizingMaskIntoConstraints = false view.addSubview(collectionView) NSLayoutConstraint.activate([ collectionView.leadingAnchor.constraint(equalTo: view.leadingAnchor), collectionView.trailingAnchor.constraint(equalTo: view.trailingAnchor), collectionView.topAnchor.constraint(equalTo: view.topAnchor), collectionView.bottomAnchor.constraint(equalTo: view.bottomAnchor), ]) registerTimelineLikeCells() collectionView.register(PublicTimelineDescriptionCollectionViewCell.self, forCellWithReuseIdentifier: "publicTimelineDescription") dataSource = createDataSource() applyInitialSnapshot() #if !targetEnvironment(macCatalyst) collectionView.refreshControl = UIRefreshControl() collectionView.refreshControl!.addTarget(self, action: #selector(refresh), for: .valueChanged) #endif contentOffsetObservation = collectionView.observe(\.contentOffset) { [weak self] _, _ in if let indexPath = self?.dataSource.indexPath(for: .gap), let cell = self?.collectionView.cellForItem(at: indexPath) as? TimelineGapCollectionViewCell { cell.update() } } filterer.filtersChanged = { [unowned self] actionsChanged in self.reapplyFilters(actionsChanged: actionsChanged) } NotificationCenter.default.addObserver(self, selector: #selector(sceneWillEnterForeground), name: UIScene.willEnterForegroundNotification, object: nil) } // separate method because InstanceTimelineViewController needs to be able to customize it func configureStatusCell(_ cell: TimelineStatusCollectionViewCell, id: String, state: CollapseState, filterResult: Filterer.Result, precomputedContent: NSAttributedString?) { cell.delegate = self if case .home = timeline { cell.showFollowedHashtags = true } else { cell.showFollowedHashtags = false } cell.updateUI(statusID: id, state: state, filterResult: filterResult, precomputedContent: precomputedContent) } private func createDataSource() -> UICollectionViewDiffableDataSource { let statusCell = UICollectionView.CellRegistration { [unowned self] cell, indexPath, item in self.configureStatusCell(cell, id: item.0, state: item.1, filterResult: item.2, precomputedContent: item.3) } let zeroHeightCell = UICollectionView.CellRegistration { _, _, _ in } let gapCell = UICollectionView.CellRegistration { cell, indexPath, _ in cell.showsIndicator = false } let timelineDescriptionCell = UICollectionView.CellRegistration { [unowned self] cell, indexPath, item in guard case .public(let local) = timeline else { fatalError() } cell.mastodonController = self.mastodonController cell.local = local cell.didDismiss = { [unowned self] in self.removeTimelineDescriptionCell() } } return UICollectionViewDiffableDataSource(collectionView: collectionView) { [unowned self] collectionView, indexPath, itemIdentifier in switch itemIdentifier { case .status(id: let id, collapseState: let state, filterState: let filterState): let (result, attributedString) = filterResult(state: filterState, statusID: id) switch result { case .allow, .warn(_): return collectionView.dequeueConfiguredReusableCell(using: statusCell, for: indexPath, item: (id, state, result, nil)) case .hide: return collectionView.dequeueConfiguredReusableCell(using: zeroHeightCell, for: indexPath, item: ()) } case .gap: return collectionView.dequeueConfiguredReusableCell(using: gapCell, for: indexPath, item: ()) case .loadingIndicator: return loadingIndicatorCell(for: indexPath) case .confirmLoadMore: return confirmLoadMoreCell(for: indexPath) case .publicTimelineDescription: self.isShowingTimelineDescription = true return collectionView.dequeueConfiguredReusableCell(using: timelineDescriptionCell, for: indexPath, item: itemIdentifier) } } } // non-private, because ListTimelineViewController needs to be able to reload it from scratch func applyInitialSnapshot() { var snapshot = NSDiffableDataSourceSnapshot() if case .public(let local) = timeline, (local && !Preferences.shared.hasShownLocalTimelineDescription) || (!local && !Preferences.shared.hasShownFederatedTimelineDescription) { snapshot.appendSections([.header]) snapshot.appendItems([.publicTimelineDescription], toSection: .header) } dataSource.apply(snapshot, animatingDifferences: false) } override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) collectionView.indexPathsForSelectedItems?.forEach { collectionView.deselectItem(at: $0, animated: true) } if case .notLoadedInitial = controller.state { if doRestore() { Task { await checkPresent() } } else { Task { await controller.loadInitial() } } } } override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) if isShowingTimelineDescription, case .public(let local) = timeline { if local { Preferences.shared.hasShownLocalTimelineDescription = true } else { Preferences.shared.hasShownFederatedTimelineDescription = true } } } func stateRestorationActivity() -> NSUserActivity? { guard isViewLoaded else { return nil } let visible = collectionView.indexPathsForVisibleItems.sorted() let snapshot = dataSource.snapshot() let visibleRect = CGRect(origin: collectionView.contentOffset, size: collectionView.bounds.size) let midPoint = CGPoint(x: visibleRect.midX, y: visibleRect.midY) guard let currentAccountID = mastodonController.accountInfo?.id, !visible.isEmpty, let statusesSection = snapshot.sectionIdentifiers.firstIndex(of: .statuses), let rawCenterVisible = collectionView.indexPathForItem(at: midPoint), let centerVisible = visible.first(where: { $0.section == statusesSection && $0 >= rawCenterVisible }) else { return nil } let allItems = snapshot.itemIdentifiers(inSection: .statuses) let startIndex = max(0, centerVisible.row - 20) let endIndex = min(allItems.count - 1, centerVisible.row + 20) let centerVisibleItem: Item var items = allItems[startIndex...endIndex] if let gapIndex = items.firstIndex(of: .gap) { // if the gap is above the top visible item, we take everything below the gap // otherwise, we take everything above the gap if gapIndex <= centerVisible.row { items = allItems[(gapIndex + 1)...endIndex] if gapIndex == centerVisible.row { centerVisibleItem = allItems.first! } else { assert(items.indices.contains(centerVisible.row)) centerVisibleItem = allItems[centerVisible.row] } } else { items = allItems[startIndex.. Bool { guard let activity = activityToRestore else { return false } guard let statusIDs = activity.userInfo?["statusIDs"] as? [String] else { stateRestorationLogger.fault("TimelineViewController: activity missing statusIDs") return false } activityToRestore = nil loadViewIfNeeded() controller.restoreInitial { var snapshot = dataSource.snapshot() snapshot.appendSections([.statuses]) let items = statusIDs.map { Item.status(id: $0, collapseState: .unknown, filterState: .unknown) } snapshot.appendItems(items, toSection: .statuses) dataSource.apply(snapshot, animatingDifferences: false) { if let centerID = activity.userInfo?["centerID"] as? String ?? activity.userInfo?["topID"] as? String, let index = statusIDs.firstIndex(of: centerID), let indexPath = self.dataSource.indexPath(for: items[index]) { // it sometimes takes multiple attempts to convert on the right scroll position // since we're dealing with a bunch of unmeasured cells, so just try a few times in a loop var count = 0 while count < 5 { count += 1 let origOffset = self.collectionView.contentOffset self.collectionView.layoutIfNeeded() self.collectionView.scrollToItem(at: indexPath, at: .centeredVertically, animated: false) let newOffset = self.collectionView.contentOffset if abs(origOffset.y - newOffset.y) <= 1 { break } } stateRestorationLogger.fault("TimelineViewController: restored statuses with center ID \(centerID)") } else { stateRestorationLogger.fault("TimelineViewController: restored statuses, but couldn't find center ID") } } } return true } private func removeTimelineDescriptionCell() { var snapshot = dataSource.snapshot() snapshot.deleteSections([.header]) dataSource.apply(snapshot, animatingDifferences: true) isShowingTimelineDescription = false } 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 return status.reblog ?? status } 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 { // need to reload not just reconfigure because hidden posts use a separate cell type snapshot.reloadItems(items) } else { // reconfigure when possible to avoid the content offset jumping around snapshot.reconfigureItems(items) } dataSource.apply(snapshot) } @objc private func sceneWillEnterForeground(_ notification: Foundation.Notification) { guard let scene = notification.object as? UIScene, // view.window is nil when the VC is not on screen view.window?.windowScene == scene else { return } Task { await checkPresent() } } @objc func refresh() { Task { if case .notLoadedInitial = controller.state { await controller.loadInitial() #if !targetEnvironment(macCatalyst) collectionView.refreshControl?.endRefreshing() #endif } else { @MainActor func loadNewerAndEndRefreshing() async { await controller.loadNewer() #if !targetEnvironment(macCatalyst) collectionView.refreshControl?.endRefreshing() #endif } // I'm not sure whether this should move into TimelineLikeController/TimelineLikeCollectionViewController let (_, presentItems) = await (loadNewerAndEndRefreshing(), try? loadInitial()) if let presentItems, !presentItems.isEmpty { insertPresentItemsIfNecessary(presentItems) } } } } private func checkPresent() async { if case .idle = controller.state, let presentItems = try? await loadInitial() { insertPresentItemsIfNecessary(presentItems) } } private func insertPresentItemsIfNecessary(_ presentItems: [String]) { let snapshot = dataSource.snapshot() guard snapshot.indexOfSection(.statuses) != nil else { return } let currentItems = snapshot.itemIdentifiers(inSection: .statuses) if case .status(id: let firstID, _, _) = currentItems.first, // if there's no overlap between presentItems and the existing items in the data source, prompt the user !presentItems.contains(firstID) { // create a new snapshot to reset the timeline to the "present" state var snapshot = NSDiffableDataSourceSnapshot() snapshot.appendSections([.statuses]) snapshot.appendItems(presentItems.map { .status(id: $0, collapseState: .unknown, filterState: .unknown) }, toSection: .statuses) var config = ToastConfiguration(title: "Jump to present") config.edge = .top config.systemImageName = "arrow.up" config.dismissAutomaticallyAfter = 4 config.action = { [unowned self] toast in toast.dismissToast(animated: true) let origSnapshot = self.dataSource.snapshot() let origItemAtTop: (Item, CGFloat)? if let statusesSection = origSnapshot.indexOfSection(.statuses), let indexPath = self.collectionView.indexPathsForVisibleItems.sorted().first(where: { $0.section == statusesSection }), let cell = self.collectionView.cellForItem(at: indexPath), let item = self.dataSource.itemIdentifier(for: indexPath) { origItemAtTop = (item, cell.convert(.zero, to: self.view).y - self.view.safeAreaInsets.top) } else { origItemAtTop = nil } self.dataSource.apply(snapshot, animatingDifferences: true) { self.collectionView.scrollToItem(at: IndexPath(row: 0, section: 0), at: .top, animated: true) var config = ToastConfiguration(title: "Go back") config.edge = .top config.systemImageName = "arrow.down" config.dismissAutomaticallyAfter = 4 config.action = { [unowned self] toast in toast.dismissToast(animated: true) // todo: it would be nice if we could animate this, but that doesn't work with the screen-position-maintaining stuff if let (item, offset) = origItemAtTop { self.applySnapshot(snapshot, maintainingScreenPosition: offset, ofItem: item) } else { self.dataSource.apply(origSnapshot, animatingDifferences: false) } } self.showToast(configuration: config, animated: true) } } self.showToast(configuration: config, animated: true) } } // NOTE: this only works when items are being inserted ABOVE the item to maintain private func applySnapshot(_ snapshot: NSDiffableDataSourceSnapshot, maintainingBottomRelativeScrollPositionOf itemToMaintain: Item) { var firstItemAfterOriginalGapOffsetFromTop: CGFloat = 0 if let indexPath = dataSource.indexPath(for: itemToMaintain), let cell = collectionView.cellForItem(at: indexPath) { // subtract top safe area inset b/c scrollToItem at .top aligns the top of the cell to the top of the safe area firstItemAfterOriginalGapOffsetFromTop = cell.convert(.zero, to: view).y - view.safeAreaInsets.top } applySnapshot(snapshot, maintainingScreenPosition: firstItemAfterOriginalGapOffsetFromTop, ofItem: itemToMaintain) } private func applySnapshot(_ snapshot: NSDiffableDataSourceSnapshot, maintainingScreenPosition offsetFromTop: CGFloat, ofItem itemToMaintain: Item) { // use a snapshot of the collection view to hide the flicker as the content offset changes and then changes back let snapshotView = collectionView.snapshotView(afterScreenUpdates: false)! snapshotView.layer.zPosition = 1000 snapshotView.frame = view.bounds view.addSubview(snapshotView) dataSource.apply(snapshot, animatingDifferences: false) { if let indexPathOfItemToMaintain = self.dataSource.indexPath(for: itemToMaintain) { // scroll up until we've accumulated enough MEASURED height that we can put the // firstItemAfterOriginalGapCell at the top of the screen and then scroll down by // firstItemAfterOriginalGapOffsetFromTop without intruding into unmeasured area var cur = indexPathOfItemToMaintain var amountScrolledUp: CGFloat = 0 while true { if cur.row <= 0 { break } if let cell = self.collectionView.cellForItem(at: indexPathOfItemToMaintain), cell.convert(.zero, to: self.view).y - self.view.safeAreaInsets.top > offsetFromTop { break } cur = IndexPath(row: cur.row - 1, section: cur.section) self.collectionView.scrollToItem(at: cur, at: .top, animated: false) self.collectionView.layoutIfNeeded() let attrs = self.collectionView.layoutAttributesForItem(at: cur)! amountScrolledUp += attrs.size.height } self.collectionView.contentOffset.y += amountScrolledUp self.collectionView.contentOffset.y -= offsetFromTop } snapshotView.removeFromSuperview() } } } extension TimelineViewController { enum Section: TimelineLikeCollectionViewSection { case header case statuses case footer static var entries: Self { .statuses } } enum Item: TimelineLikeCollectionViewItem { typealias TimelineItem = String // status ID case status(id: String, collapseState: CollapseState, filterState: FilterState) case gap case loadingIndicator case confirmLoadMore case publicTimelineDescription static func fromTimelineItem(_ id: String) -> Self { return .status(id: id, collapseState: .unknown, filterState: .unknown) } static func ==(lhs: Item, rhs: Item) -> Bool { switch (lhs, rhs) { case let (.status(id: a, _, _), .status(id: b, _, _)): return a == b case (.gap, .gap): return true case (.loadingIndicator, .loadingIndicator): return true case (.confirmLoadMore, .confirmLoadMore): return true case (.publicTimelineDescription, .publicTimelineDescription): return true default: return false } } func hash(into hasher: inout Hasher) { switch self { case .status(id: let id, _, _): hasher.combine(0) hasher.combine(id) case .gap: hasher.combine(1) case .loadingIndicator: hasher.combine(2) case .confirmLoadMore: hasher.combine(3) case .publicTimelineDescription: hasher.combine(4) } } var hideSeparators: Bool { switch self { case .loadingIndicator, .publicTimelineDescription, .confirmLoadMore: return true default: return false } } var isSelectable: Bool { switch self { case .publicTimelineDescription, .gap, .status(_, _, _): return true default: return false } } } } // MARK: TimelineLikeControllerDelegate extension TimelineViewController { typealias TimelineItem = String // status ID func loadInitial() async throws -> [TimelineItem] { try await Task.sleep(nanoseconds: 1 * NSEC_PER_SEC) let request = Client.getStatuses(timeline: timeline) let (statuses, _) = try await mastodonController.run(request) await withCheckedContinuation { continuation in mastodonController.persistentContainer.addAll(statuses: statuses) { continuation.resume() } } return statuses.map(\.id) } func loadNewer() async throws -> [TimelineItem] { let statusesSection = dataSource.snapshot().indexOfSection(.statuses)! guard case .status(id: let id, _, _) = dataSource.itemIdentifier(for: IndexPath(row: 0, section: statusesSection)) else { throw Error.noNewer } let newer = RequestRange.after(id: id, count: nil) let request = Client.getStatuses(timeline: timeline, range: newer) let (statuses, _) = try await mastodonController.run(request) guard !statuses.isEmpty else { throw TimelineViewController.Error.allCaughtUp } await withCheckedContinuation { continuation in mastodonController.persistentContainer.addAll(statuses: statuses) { continuation.resume() } } return statuses.map(\.id) } func loadOlder() async throws -> [TimelineItem] { let snapshot = dataSource.snapshot() let statusesSection = snapshot.indexOfSection(.statuses)! guard case .status(id: let id, _, _) = dataSource.itemIdentifier(for: IndexPath(row: snapshot.numberOfItems(inSection: .statuses) - 1, section: statusesSection)) else { throw Error.noNewer } let older = RequestRange.before(id: id, count: nil) let request = Client.getStatuses(timeline: timeline, range: older) let (statuses, _) = try await mastodonController.run(request) guard !statuses.isEmpty else { return [] } await withCheckedContinuation { continuation in mastodonController.persistentContainer.addAll(statuses: statuses) { continuation.resume() } } return statuses.map(\.id) } func loadGap(in direction: TimelineGapDirection) async throws -> [TimelineItem] { guard let gapIndexPath = dataSource.indexPath(for: .gap) else { throw Error.noGap } let statusItemsCount = collectionView.numberOfItems(inSection: gapIndexPath.section) let range: RequestRange switch direction { case .above: guard gapIndexPath.row > 0, case .status(id: let id, _, _) = dataSource.itemIdentifier(for: IndexPath(row: gapIndexPath.row - 1, section: gapIndexPath.section)) else { // not really the right error but w/e throw Error.noGap } range = .before(id: id, count: nil) case .below: guard gapIndexPath.row < statusItemsCount - 1, case .status(id: let id, _, _) = dataSource.itemIdentifier(for: IndexPath(row: gapIndexPath.row + 1, section: gapIndexPath.section)) else { throw Error.noGap } range = .after(id: id, count: nil) } let request = Client.getStatuses(timeline: timeline, range: range) let (statuses, _) = try await mastodonController.run(request) guard !statuses.isEmpty else { return [] } // NOTE: closing the gap (if necessary) happens in handleFillGap await withCheckedContinuation { continuation in mastodonController.persistentContainer.addAll(statuses: statuses) { continuation.resume() } } return statuses.map(\.id) } func handleLoadGapError(_ error: Swift.Error, direction: TimelineGapDirection) async { // TODO: better title, involving direction? let config = ToastConfiguration(from: error, with: "Error Loading", in: self) { [weak self] toast in toast.dismissToast(animated: true) Task { await self?.controller.fillGap(in: direction) } } self.showToast(configuration: config, animated: true) } func handleFillGap(_ timelineItems: [String], direction: TimelineGapDirection) async { var snapshot = dataSource.snapshot() let addedItems: Bool let statusItems = snapshot.itemIdentifiers(inSection: .statuses) let gapIndex = statusItems.firstIndex(of: .gap)! switch direction { case .above: // dropFirst to remove .gap item let afterGap = statusItems[gapIndex...].dropFirst().prefix(20) precondition(!afterGap.contains(.gap)) // if there is any overlap, the first overlapping item will be the first item below the gap var indexOfFirstTimelineItemExistingBelowGap: Int? if case .status(id: let id, _, _) = afterGap.first { indexOfFirstTimelineItemExistingBelowGap = timelineItems.firstIndex(of: id) } // the end index of the range of timelineItems that don't yet exist in the data source let endIndex = indexOfFirstTimelineItemExistingBelowGap ?? timelineItems.endIndex let toInsert = timelineItems[.. Bool { return dataSource.itemIdentifier(for: indexPath)?.isSelectable ?? false } func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { guard let item = dataSource.itemIdentifier(for: indexPath) else { return } switch item { case .publicTimelineDescription: removeTimelineDescriptionCell() case .status(id: let id, collapseState: let collapseState, filterState: let filterState): 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)! // if the status in the timeline is a reblog, show the status that it is a reblog of selected(status: status.reblog?.id ?? id, state: collapseState.copy()) } case .gap: let cell = collectionView.cellForItem(at: indexPath) as! TimelineGapCollectionViewCell cell.showsIndicator = true Task { await controller.fillGap(in: cell.direction) cell.showsIndicator = false } case .loadingIndicator, .confirmLoadMore: fatalError("unreachable") } } 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 scrollViewWillBeginDragging(_ scrollView: UIScrollView) { if isShowingTimelineDescription { removeTimelineDescriptionCell() } } } extension TimelineViewController: UICollectionViewDragDelegate { func collectionView(_ collectionView: UICollectionView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath) -> [UIDragItem] { (collectionView.cellForItem(at: indexPath) as? TimelineStatusCollectionViewCell)?.dragItemsForBeginning(session: session) ?? [] } } extension TimelineViewController: TuskerNavigationDelegate { var apiController: MastodonController! { mastodonController } } extension TimelineViewController: MenuActionProvider { } extension TimelineViewController: 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) = item { filterer.setResult(.allow, for: filterState) var snapshot = dataSource.snapshot() snapshot.reconfigureItems([item]) dataSource.apply(snapshot, animatingDifferences: true) } } } extension TimelineViewController: TabBarScrollableViewController { func tabBarScrollToTop() { collectionView.scrollToTop() } } extension TimelineViewController: StatusBarTappableViewController { func handleStatusBarTapped(xPosition: CGFloat) -> StatusBarTapActionResult { collectionView.scrollToTop() return .stop } }