// // TimelineViewController.swift // Tusker // // Created by Shadowfacts on 9/20/22. // Copyright © 2022 Shadowfacts. All rights reserved. // import UIKit import Pachyderm import Combine import Sentry protocol TimelineViewControllerDelegate: AnyObject { func timelineViewController(_ timelineViewController: TimelineViewController, willShowJumpToPresentToastWith animator: UIViewPropertyAnimator?) func timelineViewController(_ timelineViewController: TimelineViewController, willDismissJumpToPresentToastWith animator: UIViewPropertyAnimator?) func timelineViewController(_ timelineViewController: TimelineViewController, willShowSyncToastWith animator: UIViewPropertyAnimator?) func timelineViewController(_ timelineViewController: TimelineViewController, willDismissSyncToastWith animator: UIViewPropertyAnimator?) } extension TimelineViewControllerDelegate { func timelineViewController(_ timelineViewController: TimelineViewController, willShowJumpToPresentToastWith animator: UIViewPropertyAnimator?) {} func timelineViewController(_ timelineViewController: TimelineViewController, willDismissJumpToPresentToastWith animator: UIViewPropertyAnimator?) {} func timelineViewController(_ timelineViewController: TimelineViewController, willShowSyncToastWith animator: UIViewPropertyAnimator?) {} func timelineViewController(_ timelineViewController: TimelineViewController, willDismissSyncToastWith animator: UIViewPropertyAnimator?) {} } class TimelineViewController: UIViewController, TimelineLikeCollectionViewController, CollectionViewController, RefreshableViewController, StateRestorableViewController { weak var delegate: TimelineViewControllerDelegate? let timeline: Timeline weak var mastodonController: MastodonController! let filterer: Filterer var persistsState = false 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 cancellables = Set() private var userActivityNeedsUpdate = PassthroughSubject() private var contentOffsetObservation: NSKeyValueObservation? // the last time this VC disappeared or the scene was backgrounded while it was active, used to decide if we want to check for present when reappearing private var disappearedAt: Date? 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.monospaceFont = TimelineStatusCollectionViewCell.monospaceFont 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")) if let accountID = mastodonController.accountInfo?.id { self.userActivity = UserActivityManager.showTimelineActivity(timeline: timeline, accountID: accountID) } } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func viewDidLoad() { super.viewDidLoad() 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) = item, filterer.isKnownHide(state: filterState) { config.topSeparatorVisibility = .hidden config.bottomSeparatorVisibility = .hidden } else { config.topSeparatorInsets = TimelineStatusCollectionViewCell.separatorInsets config.bottomSeparatorInsets = TimelineStatusCollectionViewCell.separatorInsets } return config } // just setting layout.configuration.contentInsetsReference doesn't work with UICollectionViewCompositionalLayout.list let layout = UICollectionViewCompositionalLayout { sectionIndex, environment in let section = NSCollectionLayoutSection.list(using: config, layoutEnvironment: environment) if UIDevice.current.userInterfaceIdiom == .pad || UIDevice.current.userInterfaceIdiom == .mac { section.contentInsetsReference = .readableContent } return section } collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout) collectionView.delegate = self collectionView.dragDelegate = self collectionView.allowsFocus = true collectionView.backgroundColor = .appBackground 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) NotificationCenter.default.addObserver(self, selector: #selector(sceneDidEnterBackground), name: UIScene.didEnterBackgroundNotification, object: nil) NotificationCenter.default.publisher(for: .timelinePositionChanged) .filter { [unowned self] in if Preferences.shared.timelineSyncMode == .icloud, let timelinePosition = $0.object as? TimelinePosition, timelinePosition.accountID == self.mastodonController.accountInfo?.id, timelinePosition.timeline == self.timeline { return true } else { return false } } .debounce(for: .milliseconds(500), scheduler: DispatchQueue.main) .sink { [unowned self] _ in Task { _ = await syncPositionFromICloudIfNecessary(alwaysPrompt: true) } } .store(in: &cancellables) NotificationCenter.default.addObserver(self, selector: #selector(handleStatusDeleted), name: .statusDeleted, object: nil) if userActivity != nil { userActivityNeedsUpdate .debounce(for: .seconds(1), scheduler: DispatchQueue.main) .sink { [unowned self] _ in if let info = self.timelinePositionInfo(), let userActivity = self.userActivity { UserActivityManager.addTimelinePositionInfo(to: userActivity, statusIDs: info.statusIDs, centerStatusID: info.centerStatusID) } } .store(in: &cancellables) } } // 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 { [unowned self] cell, indexPath, _ in cell.showsIndicator = false cell.fillGap = { [unowned self] direction in await self.controller.fillGap(in: direction) } } 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, attributedString)) 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) clearSelectionOnAppear(animated: animated) if case .notLoadedInitial = controller.state { Task { if await restoreState() { await checkPresent(jumpImmediately: false, animateImmediateJump: false) } else { await controller.loadInitial() } } } else { syncAndCheckPresentIfEnoughTimeElapsed() } } 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 } } } override func viewDidDisappear(_ animated: Bool) { super.viewDidDisappear(animated) disappearedAt = Date() saveState() } private func currentCenterVisibleIndexPath(snapshot: NSDiffableDataSourceSnapshot?) -> IndexPath? { let snapshot = snapshot ?? dataSource.snapshot() let visible = collectionView.indexPathsForVisibleItems.sorted() let visibleRect = CGRect(origin: collectionView.contentOffset, size: collectionView.bounds.size) let midPoint = CGPoint(x: visibleRect.midX, y: visibleRect.midY) guard !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 } return centerVisible } private func timelinePositionInfo() -> (statusIDs: [String], centerStatusID: String)? { guard isViewLoaded else { return nil } let snapshot = dataSource.snapshot() guard let centerVisible = currentCenterVisibleIndexPath(snapshot: snapshot) 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.. NSUserActivity? { guard isViewLoaded, let currentAccountID = mastodonController.accountInfo?.id else { return nil } let activity = UserActivityManager.showTimelineActivity(timeline: timeline, accountID: currentAccountID)! activity.isEligibleForPrediction = false return activity } func restoreState() async -> Bool { guard persistsState, Preferences.shared.timelineStateRestoration else { return false } loadViewIfNeeded() var loaded = false await controller.restoreInitial { @MainActor in switch Preferences.shared.timelineSyncMode { case .icloud: guard let position = mastodonController.persistentContainer.getTimelinePosition(timeline: timeline) else { return } let hasStatusesToRestore = await loadStatusesToRestore(position: position) if hasStatusesToRestore { applyItemsToRestore(position: position) loaded = true } case .mastodon: guard case .home = timeline else { return } loaded = await restoreStatusesFromMarkerPosition() } } return loaded } func restoreStateFromHandoff(statusIDs: [String], centerStatusID: String) async { let crumb = Breadcrumb(level: .debug, category: "TimelineViewController") crumb.message = "Restoring state from handoff activity" SentrySDK.addBreadcrumb(crumb) await controller.restoreInitial { @MainActor in let position = TimelinePosition(context: mastodonController.persistentContainer.viewContext) position.statusIDs = statusIDs position.centerStatusID = centerStatusID let hasStatusesToRestore = await loadStatusesToRestore(position: position) if hasStatusesToRestore { applyItemsToRestore(position: position) } mastodonController.persistentContainer.viewContext.delete(position) } } @MainActor private func loadStatusesToRestore(position: TimelinePosition) async -> Bool { let originalPositionStatusIDs = position.statusIDs let unloaded = position.statusIDs.filter({ mastodonController.persistentContainer.status(for: $0) == nil }) guard !unloaded.isEmpty else { return true } let results = await withTaskGroup(of: (String, Result).self) { group -> [(String, Result)] in for id in unloaded { group.addTask { do { let (status, _) = try await self.mastodonController.run(Client.getStatus(id: id)) return (id, .success(status)) } catch { return (id, .failure(error)) } } } return await group.reduce(into: []) { partialResult, result in partialResult.append(result) } } var statuses = [Status]() for (id, result) in results { switch result { case .success(let status): statuses.append(status) case .failure(let error): let crumb = Breadcrumb(level: .error, category: "TimelineViewController") crumb.message = "Error loading status" crumb.data = [ "error": String(describing: error), "id": id ] SentrySDK.addBreadcrumb(crumb) } } await mastodonController.persistentContainer.addAll(statuses: statuses, in: mastodonController.persistentContainer.viewContext) // if an icloud sync completed in between starting to load the statuses and finishing, try to load again if position.statusIDs != originalPositionStatusIDs { let crumb = Breadcrumb(level: .info, category: "TimelineViewController") crumb.message = "TimelinePosition statusIDs changed, retrying load" SentrySDK.addBreadcrumb(crumb) return await loadStatusesToRestore(position: position) } // update the timeline position in case some statuses couldn't be loaded if let center = position.centerStatusID, let centerIndex = position.statusIDs.firstIndex(of: center) { let nearestLoadedStatusToCenter = position.statusIDs[centerIndex...].first(where: { id in // was already loaded or was just now loaded !unloaded.contains(id) || statuses.contains(where: { $0.id == id }) }) position.centerStatusID = nearestLoadedStatusToCenter } position.statusIDs = position.statusIDs.filter { id in !unloaded.contains(id) || statuses.contains(where: { $0.id == id }) } return !position.statusIDs.isEmpty } @MainActor private func applyItemsToRestore(position: TimelinePosition) { var snapshot = NSDiffableDataSourceSnapshot() snapshot.appendSections([.statuses]) let statusIDs = position.statusIDs let centerStatusID = position.centerStatusID let items = position.statusIDs.map { Item.status(id: $0, collapseState: .unknown, filterState: .unknown) } snapshot.appendItems(items, toSection: .statuses) let crumb = Breadcrumb(level: .info, category: "TimelineViewController") crumb.message = "Restoring statuses" crumb.data = [ "statusIDs": position.statusIDs ] SentrySDK.addBreadcrumb(crumb) dataSource.apply(snapshot, animatingDifferences: false) { if let centerStatusID, let index = statusIDs.firstIndex(of: centerStatusID) { self.scrollToItem(item: items[index]) stateRestorationLogger.fault("TimelineViewController: restored statuses with center ID \(centerStatusID)") } else { stateRestorationLogger.fault("TimelineViewController: restored statuses, but couldn't find center ID") } } } @MainActor private func restoreStatusesFromMarkerPosition() async -> Bool { do { let (marker, _) = try await mastodonController.run(TimelineMarkers.request(timelines: [.home])) guard let home = marker.home else { return false } async let status = try await mastodonController.run(Client.getStatus(id: home.lastReadID)).0 async let olderStatuses = try await mastodonController.run(Client.getStatuses(timeline: .home, range: .before(id: home.lastReadID, count: Self.pageSize))).0 let allStatuses = try await [status] + olderStatuses await mastodonController.persistentContainer.addAll(statuses: allStatuses) var snapshot = NSDiffableDataSourceSnapshot() snapshot.appendSections([.statuses]) let items = allStatuses.map { Item.status(id: $0.id, collapseState: .unknown, filterState: .unknown) } snapshot.appendItems(items) await dataSource.apply(snapshot, animatingDifferences: false) collectionView.contentOffset = CGPoint(x: 0, y: -collectionView.adjustedContentInset.top) stateRestorationLogger.debug("TimelineViewController: restored from timeline marker with last read ID: \(home.lastReadID)") return true } catch { stateRestorationLogger.error("TimelineViewController: failed to load from timeline marker: \(String(describing: error))") let event = Event(error: error) event.message = SentryMessage(formatted: "Failed to load from timeline marker") SentrySDK.capture(event: event) return false } } private func scrollToItem(item: Item) { guard let indexPath = dataSource.indexPath(for: item) else { return } // 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 } } } private func removeTimelineDescriptionCell() { var snapshot = dataSource.snapshot() snapshot.deleteItems([.publicTimelineDescription]) dataSource.apply(snapshot, animatingDifferences: true) isShowingTimelineDescription = false } private func filterResult(state: FilterState, statusID: String) -> (Filterer.Result, NSAttributedString?) { let status = { guard let status = self.mastodonController.persistentContainer.status(for: statusID) else { let crumb = Breadcrumb(level: .fatal, category: "TimelineViewController") crumb.message = "Looking up status \(statusID)" SentrySDK.addBreadcrumb(crumb) preconditionFailure("Missing status for filtering") } // 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 { // 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 } syncAndCheckPresentIfEnoughTimeElapsed() } @objc private func sceneDidEnterBackground(_ 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 } disappearedAt = Date() saveState() } func syncPositionIfNecessary(alwaysPrompt: Bool) async -> Bool { switch Preferences.shared.timelineSyncMode { case .icloud: return await syncPositionFromICloudIfNecessary(alwaysPrompt: alwaysPrompt) case .mastodon: return await restoreStatusesFromMarkerPosition() } } private func syncPositionFromICloudIfNecessary(alwaysPrompt: Bool) async -> Bool { guard persistsState, let timelinePosition = mastodonController.persistentContainer.getTimelinePosition(timeline: timeline) else { return false } let snapshot = dataSource.snapshot() guard let centerVisible = currentCenterVisibleIndexPath(snapshot: snapshot), snapshot.sectionIdentifiers.contains(.statuses) else { return false } let statusesSection = snapshot.itemIdentifiers(inSection: .statuses) let centerVisibleStatusID: String switch statusesSection[centerVisible.row] { case .gap: guard case .status(let id, _, _) = statusesSection[centerVisible.row + 1] else { fatalError() } centerVisibleStatusID = id case .status(let id, _, _): centerVisibleStatusID = id default: fatalError() } guard timelinePosition.centerStatusID != centerVisibleStatusID else { return false } stateRestorationLogger.info("Potential restore with centerStatusID: \(timelinePosition.centerStatusID ?? "")") if !alwaysPrompt { _ = await restoreState() } else { var config = ToastConfiguration(title: "Sync Position") config.edge = .top config.dismissAutomaticallyAfter = 5 config.systemImageName = "arrow.triangle.2.circlepath" config.action = { [unowned self] toast in toast.isUserInteractionEnabled = false UIView.animateKeyframes(withDuration: 1, delay: 0, options: .repeat) { UIView.addKeyframe(withRelativeStartTime: 0, relativeDuration: 0.5) { toast.imageView!.transform = CGAffineTransform(rotationAngle: 0.5 * .pi) } UIView.addKeyframe(withRelativeStartTime: 0.5, relativeDuration: 0.5) { // the translation is because the symbol isn't perfectly centered toast.imageView!.transform = CGAffineTransform(translationX: -0.5, y: 0).rotated(by: .pi) } } Task { _ = await self.restoreState() toast.dismissToast(animated: true) } } config.onAppear = { [unowned self] animator in self.delegate?.timelineViewController(self, willShowSyncToastWith: animator) } config.onDismiss = { [unowned self] animator in self.delegate?.timelineViewController(self, willDismissSyncToastWith: animator) } showToast(configuration: config, animated: true) UIAccessibility.post(notification: .announcement, argument: "Synced Position Updated") } return true } @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 { insertPresentItemsAndShowJumpToast(presentItems) } } } } private func syncAndCheckPresentIfEnoughTimeElapsed() { guard let disappearedAt, -disappearedAt.timeIntervalSinceNow > 60 * 60 /* 1 hour */ else { return } self.disappearedAt = nil Task { if await syncPositionIfNecessary(alwaysPrompt: false) { // no-op } else { await checkPresent(jumpImmediately: false, animateImmediateJump: false) } } } func checkPresent(jumpImmediately: Bool, animateImmediateJump: Bool) async { guard case .idle = controller.state, let presentItems = try? await loadInitial(), !presentItems.isEmpty else { return } if jumpImmediately { var snapshot = NSDiffableDataSourceSnapshot() snapshot.appendSections([.statuses]) snapshot.appendItems(presentItems.map { .status(id: $0, collapseState: .unknown, filterState: .unknown) }, toSection: .statuses) dataSource.apply(snapshot, animatingDifferences: animateImmediateJump) { self.collectionView.scrollToItem(at: IndexPath(row: 0, section: 0), at: .top, animated: animateImmediateJump) UIAccessibility.post(notification: .screenChanged, argument: self.collectionView.cellForItem(at: IndexPath(row: 0, section: 0))) } } else { insertPresentItemsAndShowJumpToast(presentItems) } } private func insertPresentItemsAndShowJumpToast(_ presentItems: [String]) { var snapshot = dataSource.snapshot() guard snapshot.indexOfSection(.statuses) != nil else { return } let currentItems = snapshot.itemIdentifiers(inSection: .statuses) func currentItemsContains(id: String) -> Bool { return currentItems.contains { item in switch item { case .status(id: id, collapseState: _, filterState: _): return true default: return false } } } // if there's no overlap between presentItems and the existing items in the data source, prompt the user // we can't be clever here by just checking the first id in currentItems against presentItems, since that may belong to a since-unfollowed user if !presentItems.contains(where: { currentItemsContains(id: $0) }) { let applySnapshotBeforeScrolling: Bool // remove any existing gap, if there is one if let index = currentItems.lastIndex(of: .gap) { snapshot.deleteItems(Array(currentItems[index...])) let statusesSection = snapshot.indexOfSection(.statuses)! if collectionView.indexPathsForVisibleItems.contains(IndexPath(row: index, section: statusesSection)) { // the gap cell is on screen applySnapshotBeforeScrolling = false } else if let topMostVisibleCell = collectionView.indexPathsForVisibleItems.first(where: { $0.section == statusesSection }), index < topMostVisibleCell.row { // the gap cell is above the top, so applying the snapshot would remove the currently-viewed statuses applySnapshotBeforeScrolling = false } else { // the gap cell is below the bottom of the screen applySnapshotBeforeScrolling = true } } else { // there is no existing gap applySnapshotBeforeScrolling = true } if let first = currentItems.first { snapshot.insertItems([.gap], beforeItem: first) } else { snapshot.appendItems([.gap], toSection: .statuses) } snapshot.insertItems(presentItems.map { .status(id: $0, collapseState: .unknown, filterState: .unknown) }, beforeItem: .gap) if applySnapshotBeforeScrolling { if let firstVisibleIndexPath = collectionView.indexPathsForVisibleItems.min() { let firstVisibleItem = dataSource.itemIdentifier(for: firstVisibleIndexPath)! applySnapshot(snapshot, maintainingBottomRelativeScrollPositionOf: firstVisibleItem) } else { dataSource.apply(snapshot, animatingDifferences: false) } } 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 } // when the user explicitly taps Jump to Present, we drop all the old items to let infinite scrolling work properly when they scroll back down var snapshot = NSDiffableDataSourceSnapshot() snapshot.appendSections([.statuses]) snapshot.appendItems(presentItems.map { .status(id: $0, collapseState: .unknown, filterState: .unknown) }, toSection: .statuses) // don't animate the snapshot change, the scrolling animation will paper over the switch self.dataSource.apply(snapshot, animatingDifferences: false) { 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 = 2 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(origSnapshot, maintainingScreenPosition: offset, ofItem: item) } else { self.dataSource.apply(origSnapshot, animatingDifferences: false) } } self.showToast(configuration: config, animated: true) } } config.onAppear = { [unowned self] animator in self.delegate?.timelineViewController(self, willShowJumpToPresentToastWith: animator) } config.onDismiss = { [unowned self] animator in self.delegate?.timelineViewController(self, willDismissJumpToPresentToastWith: animator) } 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) if let snapshotView { 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 var first = true 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 { // if we're breaking from the loop at the first iteration, we need to make sure to still call scrollToItem for the current row if first { self.collectionView.scrollToItem(at: cur, at: .top, animated: false) } 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 first = false } self.collectionView.contentOffset.y += amountScrolledUp self.collectionView.contentOffset.y -= offsetFromTop } snapshotView?.removeFromSuperview() } } @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 .map { id in Item.status(id: id, collapseState: .unknown, filterState: .unknown) } .filter { item in snapshot.itemIdentifiers.contains(item) } if !toDelete.isEmpty { snapshot.deleteItems(toDelete) self.dataSource.apply(snapshot, animatingDifferences: true) } } // this is only implemented here so it's overridable by InstanceTimelineViewController func handleLoadAllError(_ error: Swift.Error) async { let config = ToastConfiguration(from: error, with: "Error Loading", in: self) { [weak self] toast in toast.dismissToast(animated: true) Task { await self?.controller.loadInitial() } } self.showToast(configuration: config, animated: true) } } 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 // the maximum mastodon will provide in a single request private static let pageSize = 40 func loadInitial() async throws -> [TimelineItem] { let request = Client.getStatuses(timeline: timeline, range: .count(TimelineViewController.pageSize)) 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: TimelineViewController.pageSize) 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: TimelineViewController.pageSize) 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: TimelineViewController.pageSize) 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: TimelineViewController.pageSize) } 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() } } func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) { if !decelerate { userActivityNeedsUpdate.send() } } func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { userActivityNeedsUpdate.send() } } 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 } }