diff --git a/Tusker.xcodeproj/project.pbxproj b/Tusker.xcodeproj/project.pbxproj index 2b065ca0..878e4d99 100644 --- a/Tusker.xcodeproj/project.pbxproj +++ b/Tusker.xcodeproj/project.pbxproj @@ -215,6 +215,7 @@ D6AC956723C4347E008C9946 /* MainSceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6AC956623C4347E008C9946 /* MainSceneDelegate.swift */; }; D6ACE1AC240C3BAD004EA8E2 /* Ambassador.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D65F613023AE99E000F3CFD3 /* Ambassador.framework */; }; D6ACE1AD240C3BAD004EA8E2 /* Embassy.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D65F612D23AE990C00F3CFD3 /* Embassy.framework */; }; + D6ADB6E828E8C878009924AB /* PublicTimelineDescriptionCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6ADB6E728E8C878009924AB /* PublicTimelineDescriptionCollectionViewCell.swift */; }; D6AEBB3E2321638100E5038B /* UIActivity+Types.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6AEBB3D2321638100E5038B /* UIActivity+Types.swift */; }; D6AEBB412321642700E5038B /* SendMesasgeActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6AEBB402321642700E5038B /* SendMesasgeActivity.swift */; }; D6AEBB432321685E00E5038B /* OpenInSafariActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6AEBB422321685E00E5038B /* OpenInSafariActivity.swift */; }; @@ -560,6 +561,7 @@ D6A6C11425B62E9700298D0F /* CacheExpiry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CacheExpiry.swift; sourceTree = ""; }; D6A6C11A25B63CEE00298D0F /* MemoryCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemoryCache.swift; sourceTree = ""; }; D6AC956623C4347E008C9946 /* MainSceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainSceneDelegate.swift; sourceTree = ""; }; + D6ADB6E728E8C878009924AB /* PublicTimelineDescriptionCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PublicTimelineDescriptionCollectionViewCell.swift; sourceTree = ""; }; D6AEBB3D2321638100E5038B /* UIActivity+Types.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIActivity+Types.swift"; sourceTree = ""; }; D6AEBB402321642700E5038B /* SendMesasgeActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SendMesasgeActivity.swift; sourceTree = ""; }; D6AEBB422321685E00E5038B /* OpenInSafariActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenInSafariActivity.swift; sourceTree = ""; }; @@ -897,6 +899,7 @@ D6BC9DD6232D7811002CA326 /* TimelinesPageViewController.swift */, D65234D225618EFA001AF9CF /* TimelineTableViewController.swift */, D66C900A28DAB7FD00217BF2 /* TimelineViewController.swift */, + D6ADB6E728E8C878009924AB /* PublicTimelineDescriptionCollectionViewCell.swift */, ); path = Timeline; sourceTree = ""; @@ -1889,6 +1892,7 @@ D61A45E628DC0F2F002BE511 /* ConfirmLoadMoreCollectionViewCell.swift in Sources */, D663626C21361C6700C9CBA2 /* Account+Preferences.swift in Sources */, D67895C0246870DE00D4CD9E /* LocalAccountAvatarView.swift in Sources */, + D6ADB6E828E8C878009924AB /* PublicTimelineDescriptionCollectionViewCell.swift in Sources */, D6333B372137838300CE884A /* AttributedString+Helpers.swift in Sources */, D622757824EE133700B82A16 /* ComposeAssetPicker.swift in Sources */, D681A29A249AD62D0085E54E /* LargeImageContentView.swift in Sources */, diff --git a/Tusker/Screens/Timeline/PublicTimelineDescriptionCollectionViewCell.swift b/Tusker/Screens/Timeline/PublicTimelineDescriptionCollectionViewCell.swift new file mode 100644 index 00000000..dbe04b79 --- /dev/null +++ b/Tusker/Screens/Timeline/PublicTimelineDescriptionCollectionViewCell.swift @@ -0,0 +1,62 @@ +// +// PublicTimelineDescriptionCollectionViewCell.swift +// Tusker +// +// Created by Shadowfacts on 10/1/22. +// Copyright © 2022 Shadowfacts. All rights reserved. +// + +import UIKit + +class PublicTimelineDescriptionCollectionViewCell: UICollectionViewCell { + + weak var mastodonController: MastodonController! + + var local = false { + didSet { + updateLabel() + } + } + var didDismiss: (() -> Void)? + + private let label = UILabel() + + override init(frame: CGRect) { + super.init(frame: frame) + + contentView.backgroundColor = .tintColor + label.font = .preferredFont(forTextStyle: .body) + label.numberOfLines = 0 + label.textColor = .white + label.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(label) + NSLayoutConstraint.activate([ + label.leadingAnchor.constraint(equalToSystemSpacingAfter: contentView.leadingAnchor, multiplier: 1), + contentView.trailingAnchor.constraint(equalToSystemSpacingAfter: label.trailingAnchor, multiplier: 1), + label.topAnchor.constraint(equalToSystemSpacingBelow: contentView.topAnchor, multiplier: 1), + contentView.bottomAnchor.constraint(equalToSystemSpacingBelow: label.bottomAnchor, multiplier: 1), + ]) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func updateLabel() { + let str = NSMutableAttributedString() + let instanceStr = NSAttributedString(string: mastodonController.instanceURL.host!, attributes: [ + .font: UIFont(descriptor: .preferredFontDescriptor(withTextStyle: .body).withSymbolicTraits(.traitBold)!, size: 0) + ]) + if local { + str.append(NSAttributedString(string: "The local timeline shows public posts from only ")) + str.append(instanceStr) + str.append(NSAttributedString(string: ".")) + } else { + str.append(NSAttributedString(string: "The federated timeline shows public posts from all users that ")) + str.append(instanceStr) + str.append(NSAttributedString(string: " knows about.")) + } + label.attributedText = str + } + +} diff --git a/Tusker/Screens/Timeline/TimelineViewController.swift b/Tusker/Screens/Timeline/TimelineViewController.swift index 0ef4b009..17aeb738 100644 --- a/Tusker/Screens/Timeline/TimelineViewController.swift +++ b/Tusker/Screens/Timeline/TimelineViewController.swift @@ -22,6 +22,8 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro let confirmLoadMore = PassthroughSubject() private var newer: RequestRange? private var older: RequestRange? + // stored separately because i don't want to query the snapshot every time the user scrolls + private var isShowingTimelineDescription = false var collectionView: UICollectionView { view as! UICollectionView @@ -63,6 +65,7 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro // collectionView.dragDelegate = self registerTimelineLikeCells() + collectionView.register(PublicTimelineDescriptionCollectionViewCell.self, forCellWithReuseIdentifier: "publicTimelineDescription") dataSource = createDataSource() applyInitialSnapshot() @@ -94,6 +97,16 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro config.text = try! doc.text() cell.contentConfiguration = config } + 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(_, _): @@ -102,13 +115,22 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro return loadingIndicatorCell(for: indexPath) case .confirmLoadMore: return confirmLoadMoreCell(for: indexPath) + case .publicTimelineDescription: + self.isShowingTimelineDescription = true + return collectionView.dequeueConfiguredReusableCell(using: timelineDescriptionCell, for: indexPath, item: itemIdentifier) } } } private func applyInitialSnapshot() { - // TODO: this might not be necessary - // TODO: yes it is, for public timeline descriptions + if case .public(let local) = timeline, + (local && !Preferences.shared.hasShownLocalTimelineDescription) || + (!local && Preferences.shared.hasShownFederatedTimelineDescription) { + var snapshot = dataSource.snapshot() + snapshot.appendSections([.header]) + snapshot.appendItems([.publicTimelineDescription], toSection: .header) + dataSource.apply(snapshot, animatingDifferences: false) + } } override func viewWillAppear(_ animated: Bool) { @@ -119,6 +141,13 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro } } + private func removeTimelineDescriptionCell() { + var snapshot = dataSource.snapshot() + snapshot.deleteSections([.header]) + dataSource.apply(snapshot, animatingDifferences: true) + isShowingTimelineDescription = false + } + } extension TimelineViewController { @@ -135,8 +164,7 @@ extension TimelineViewController { case status(id: String, state: StatusState) case loadingIndicator case confirmLoadMore -// // TODO: remove local param from this -// case publicTimelineDescription(local: Bool) + case publicTimelineDescription static func fromTimelineItem(_ id: String) -> Self { return .status(id: id, state: .unknown) @@ -150,8 +178,8 @@ extension TimelineViewController { return true case (.confirmLoadMore, .confirmLoadMore): return true -// case let (.publicTimelineDescription(local: a), .publicTimelineDescription(local: b)): -// return a == b + case (.publicTimelineDescription, .publicTimelineDescription): + return true default: return false } @@ -166,15 +194,14 @@ extension TimelineViewController { hasher.combine(1) case .confirmLoadMore: hasher.combine(2) -// case .publicTimelineDescription(local: let local): -// hasher.combine(3) -// hasher.combine(local) + case .publicTimelineDescription: + hasher.combine(3) } } var hideSeparators: Bool { switch self { - case .loadingIndicator: + case .loadingIndicator, .publicTimelineDescription: return true default: return false @@ -192,8 +219,6 @@ extension TimelineViewController { throw Error.noClient } - try await Task.sleep(nanoseconds: 2 * NSEC_PER_SEC) - let request = Client.getStatuses(timeline: timeline) let (statuses, _) = try await mastodonController.run(request) @@ -235,8 +260,6 @@ extension TimelineViewController { throw Error.noOlder } - try await Task.sleep(nanoseconds: 2 * NSEC_PER_SEC) - let request = Client.getStatuses(timeline: timeline, range: older) let (statuses, _) = try await mastodonController.run(request) @@ -273,4 +296,24 @@ extension TimelineViewController: UICollectionViewDelegate { } } } + + func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + guard let item = dataSource.itemIdentifier(for: indexPath) else { + return + } + switch item { + case .publicTimelineDescription: + removeTimelineDescriptionCell() + + default: + // TODO: cell selection + break + } + } + + func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { + if isShowingTimelineDescription { + removeTimelineDescriptionCell() + } + } } diff --git a/Tusker/Screens/Timeline/TimelinesPageViewController.swift b/Tusker/Screens/Timeline/TimelinesPageViewController.swift index 05fb4e8e..9772b448 100644 --- a/Tusker/Screens/Timeline/TimelinesPageViewController.swift +++ b/Tusker/Screens/Timeline/TimelinesPageViewController.swift @@ -22,10 +22,10 @@ class TimelinesPageViewController: SegmentedPageViewController { let home = TimelineViewController(for: .home, mastodonController: mastodonController) home.title = homeTitle - let federated = TimelineTableViewController(for: .public(local: false), mastodonController: mastodonController) + let federated = TimelineViewController(for: .public(local: false), mastodonController: mastodonController) federated.title = federatedTitle - let local = TimelineTableViewController(for: .public(local: true), mastodonController: mastodonController) + let local = TimelineViewController(for: .public(local: true), mastodonController: mastodonController) local.title = localTitle super.init(titles: [ diff --git a/Tusker/Screens/Utilities/TimelineLikeCollectionViewController.swift b/Tusker/Screens/Utilities/TimelineLikeCollectionViewController.swift index f75a78c6..cfe9065b 100644 --- a/Tusker/Screens/Utilities/TimelineLikeCollectionViewController.swift +++ b/Tusker/Screens/Utilities/TimelineLikeCollectionViewController.swift @@ -52,7 +52,7 @@ extension TimelineLikeCollectionViewController { snapshot.appendSections([.footer]) } snapshot.appendItems([.confirmLoadMore], toSection: .footer) - await dataSource.apply(snapshot, animatingDifferences: false) + await apply(snapshot, animatingDifferences: false) } for await _ in confirmLoadMore.values { return true @@ -94,14 +94,14 @@ extension TimelineLikeCollectionViewController { } else { snapshot.appendItems([.loadingIndicator], toSection: .footer) } - await dataSource.apply(snapshot, animatingDifferences: false) + await apply(snapshot, animatingDifferences: false) } func handleRemoveLoadingIndicator() async { let oldContentOffset = collectionView.contentOffset var snapshot = dataSource.snapshot() snapshot.deleteSections([.footer]) - await dataSource.apply(snapshot, animatingDifferences: false) + await apply(snapshot, animatingDifferences: false) // prevent the collection view from scrolling as we remove the loading indicator and add the timeline items collectionView.contentOffset = oldContentOffset } @@ -123,7 +123,7 @@ extension TimelineLikeCollectionViewController { } snapshot.appendSections([.entries]) snapshot.appendItems(timelineItems.map { .fromTimelineItem($0) }, toSection: .entries) - await dataSource.apply(snapshot, animatingDifferences: false) + await apply(snapshot, animatingDifferences: false) } func handleLoadNewerError(_ error: Swift.Error) async { @@ -156,7 +156,7 @@ extension TimelineLikeCollectionViewController { } else { snapshot.appendItems(items, toSection: .entries) } - await dataSource.apply(snapshot, animatingDifferences: false) + await apply(snapshot, animatingDifferences: false) if let first, let indexPath = dataSource.indexPath(for: first) { @@ -183,11 +183,20 @@ extension TimelineLikeCollectionViewController { snapshot.deleteItems([.confirmLoadMore]) } snapshot.appendItems(timelineItems.map { .fromTimelineItem($0) }, toSection: .entries) - await dataSource.apply(snapshot, animatingDifferences: false) + await apply(snapshot, animatingDifferences: false) } } extension TimelineLikeCollectionViewController { + // apply(_:animatingDifferences:) is marked as nonisolated, so just awaiting it doesn't dispatch to the main thread, unlike other async @MainActor methods + // but we always want to update the data source on the main thread for consistency, so this method does that + func apply(_ snapshot: NSDiffableDataSourceSnapshot, animatingDifferences: Bool) async { + let task = Task { @MainActor in + self.dataSource.apply(snapshot, animatingDifferences: animatingDifferences) + } + await task.value + } + func registerTimelineLikeCells() { collectionView.register(LoadingCollectionViewCell.self, forCellWithReuseIdentifier: "loadingIndicator") collectionView.register(ConfirmLoadMoreCollectionViewCell.self, forCellWithReuseIdentifier: "confirmLoadMore")