diff --git a/Tusker/Screens/Explore/TrendingStatusesViewController.swift b/Tusker/Screens/Explore/TrendingStatusesViewController.swift index 66e1e044..885501ed 100644 --- a/Tusker/Screens/Explore/TrendingStatusesViewController.swift +++ b/Tusker/Screens/Explore/TrendingStatusesViewController.swift @@ -64,7 +64,8 @@ class TrendingStatusesViewController: UIViewController { private func createDataSource() -> UICollectionViewDiffableDataSource { let statusCell = UICollectionView.CellRegistration { [unowned self] cell, indexPath, item in cell.delegate = self - cell.updateUI(statusID: item.0, state: item.1) + // TODO: filter these + cell.updateUI(statusID: item.0, state: item.1, filterResult: .allow) } let loadingCell = UICollectionView.CellRegistration { cell, _, _ in cell.indicator.startAnimating() diff --git a/Tusker/Screens/Profile/ProfileStatusesViewController.swift b/Tusker/Screens/Profile/ProfileStatusesViewController.swift index bcbd39da..6beccf51 100644 --- a/Tusker/Screens/Profile/ProfileStatusesViewController.swift +++ b/Tusker/Screens/Profile/ProfileStatusesViewController.swift @@ -113,7 +113,7 @@ class ProfileStatusesViewController: UIViewController, TimelineLikeCollectionVie let statusCell = UICollectionView.CellRegistration { [unowned self] cell, indexPath, item in cell.delegate = self cell.showPinned = item.2 - cell.updateUI(statusID: item.0, state: item.1) + cell.updateUI(statusID: item.0, state: item.1, filterResult: .allow) } return UICollectionViewDiffableDataSource(collectionView: collectionView) { [unowned self] collectionView, indexPath, itemIdentifier in switch itemIdentifier { diff --git a/Tusker/Screens/Status Action Account List/StatusActionAccountListViewController.swift b/Tusker/Screens/Status Action Account List/StatusActionAccountListViewController.swift index 2cb57831..26d48bc7 100644 --- a/Tusker/Screens/Status Action Account List/StatusActionAccountListViewController.swift +++ b/Tusker/Screens/Status Action Account List/StatusActionAccountListViewController.swift @@ -80,7 +80,7 @@ class StatusActionAccountListViewController: UIViewController { private func createDataSource() -> UICollectionViewDiffableDataSource { let statusCell = UICollectionView.CellRegistration { [unowned self] cell, indexPath, _ in cell.delegate = self - cell.updateUI(statusID: self.statusID, state: self.statusState) + cell.updateUI(statusID: self.statusID, state: self.statusState, filterResult: .allow) } let accountCell = UICollectionView.CellRegistration { [unowned self] cell, indexPath, item in cell.delegate = self diff --git a/Tusker/Screens/Timeline/InstanceTimelineViewController.swift b/Tusker/Screens/Timeline/InstanceTimelineViewController.swift index d7d25906..5ce4a5f8 100644 --- a/Tusker/Screens/Timeline/InstanceTimelineViewController.swift +++ b/Tusker/Screens/Timeline/InstanceTimelineViewController.swift @@ -69,10 +69,10 @@ class InstanceTimelineViewController: TimelineViewController { toggleSaveButton.title = toggleSaveButtonTitle } - override func configureStatusCell(_ cell: TimelineStatusCollectionViewCell, id: String, state: CollapseState) { + override func configureStatusCell(_ cell: TimelineStatusCollectionViewCell, id: String, state: CollapseState, filterResult: Filterer.Result) { cell.delegate = browsingEnabled ? self : nil cell.overrideMastodonController = mastodonController - cell.updateUI(statusID: id, state: state) + cell.updateUI(statusID: id, state: state, filterResult: filterResult) } override func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { diff --git a/Tusker/Screens/Timeline/TimelineViewController.swift b/Tusker/Screens/Timeline/TimelineViewController.swift index a104ea5f..1e9cad15 100644 --- a/Tusker/Screens/Timeline/TimelineViewController.swift +++ b/Tusker/Screens/Timeline/TimelineViewController.swift @@ -105,27 +105,22 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro } // separate method because InstanceTimelineViewController needs to be able to customize it - func configureStatusCell(_ cell: TimelineStatusCollectionViewCell, id: String, state: CollapseState) { + func configureStatusCell(_ cell: TimelineStatusCollectionViewCell, id: String, state: CollapseState, filterResult: Filterer.Result) { cell.delegate = self if case .home = timeline { cell.showFollowedHashtags = true } else { cell.showFollowedHashtags = false } - cell.updateUI(statusID: id, state: state) + cell.updateUI(statusID: id, state: state, filterResult: filterResult) } private func createDataSource() -> UICollectionViewDiffableDataSource { - let statusCell = UICollectionView.CellRegistration { [unowned self] cell, indexPath, item in - self.configureStatusCell(cell, id: item.0, state: item.1) + let statusCell = UICollectionView.CellRegistration { [unowned self] cell, indexPath, item in + self.configureStatusCell(cell, id: item.0, state: item.1, filterResult: item.2) } let zeroHeightCell = UICollectionView.CellRegistration { _, _, _ in } - let filterWarningCell = UICollectionView.CellRegistration { cell, indexPath, item in - var config = cell.defaultContentConfiguration() - config.text = "Filtered: \(item)" - cell.contentConfiguration = config - } let gapCell = UICollectionView.CellRegistration { cell, indexPath, _ in cell.showsIndicator = false } @@ -143,13 +138,12 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro switch itemIdentifier { case .status(id: let id, collapseState: let state, filterState: let filterState): let status = { self.mastodonController.persistentContainer.status(for: id)! } - switch filterState.resolveFor(status: status, resolver: filterer) { - case .allow: - return collectionView.dequeueConfiguredReusableCell(using: statusCell, for: indexPath, item: (id, state)) + let result = filterState.resolveFor(status: status, resolver: filterer) + switch result { + case .allow, .warn(_): + return collectionView.dequeueConfiguredReusableCell(using: statusCell, for: indexPath, item: (id, state, result)) case .hide: return collectionView.dequeueConfiguredReusableCell(using: zeroHeightCell, for: indexPath, item: ()) - case .warn(let filterTitle): - return collectionView.dequeueConfiguredReusableCell(using: filterWarningCell, for: indexPath, item: filterTitle) } case .gap: return collectionView.dequeueConfiguredReusableCell(using: gapCell, for: indexPath, item: ()) @@ -787,11 +781,10 @@ extension TimelineViewController: UICollectionViewDelegate { let status = mastodonController.persistentContainer.status(for: id)! if filterState.isWarning { filterState.setResult(.allow) + collectionView.deselectItem(at: indexPath, animated: true) var snapshot = dataSource.snapshot() - snapshot.reloadItems([item]) - dataSource.apply(snapshot, animatingDifferences: true) { - collectionView.deselectItem(at: indexPath, animated: true) - } + snapshot.reconfigureItems([item]) + dataSource.apply(snapshot, animatingDifferences: true) } else { // 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()) diff --git a/Tusker/Views/Status/TimelineStatusCollectionViewCell.swift b/Tusker/Views/Status/TimelineStatusCollectionViewCell.swift index a733532f..eaafe92e 100644 --- a/Tusker/Views/Status/TimelineStatusCollectionViewCell.swift +++ b/Tusker/Views/Status/TimelineStatusCollectionViewCell.swift @@ -242,6 +242,18 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell, StatusCollecti [replyButton, favoriteButton, reblogButton, moreButton] } + private var contentViewMode: ContentViewMode! + private let statusContainer = UIView().configure { + $0.translatesAutoresizingMaskIntoConstraints = false + } + private let filteredLabel = UILabel().configure { + $0.textAlignment = .center + $0.textColor = .secondaryLabel + $0.font = UIFont(descriptor: UIFontDescriptor.preferredFontDescriptor(withTextStyle: .body).withSymbolicTraits(.traitItalic)!, size: 0) + $0.adjustsFontForContentSizeCategory = true + $0.translatesAutoresizingMaskIntoConstraints = false + } + // MARK: Cell state private var mainContainerTopToReblogLabelConstraint: NSLayoutConstraint! @@ -275,37 +287,39 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell, StatusCollecti private var updateTimestampWorkItem: DispatchWorkItem? private var hasCreatedObservers = false var cancellables = Set() - + override init(frame: CGRect) { super.init(frame: frame) + setContentViewMode(.status) + for subview in [timelineReasonHStack, mainContainer, actionsContainer] { subview.translatesAutoresizingMaskIntoConstraints = false - contentView.addSubview(subview) + statusContainer.addSubview(subview) } mainContainerTopToReblogLabelConstraint = mainContainer.topAnchor.constraint(equalTo: timelineReasonHStack.bottomAnchor, constant: 4) - mainContainerTopToSelfConstraint = mainContainer.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 8) + mainContainerTopToSelfConstraint = mainContainer.topAnchor.constraint(equalTo: statusContainer.topAnchor, constant: 8) mainContainerBottomToActionsConstraint = mainContainer.bottomAnchor.constraint(equalTo: actionsContainer.topAnchor, constant: -4) - mainContainerBottomToSelfConstraint = mainContainer.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -6) + mainContainerBottomToSelfConstraint = mainContainer.bottomAnchor.constraint(equalTo: statusContainer.bottomAnchor, constant: -6) - let metaIndicatorsBottomConstraint = metaIndicatorsView.bottomAnchor.constraint(lessThanOrEqualTo: contentView.bottomAnchor, constant: -6) + let metaIndicatorsBottomConstraint = metaIndicatorsView.bottomAnchor.constraint(lessThanOrEqualTo: statusContainer.bottomAnchor, constant: -6) // sometimes during intermediate layouts, there are conflicting constraints, so let this one get broken temporarily, to avoid a bunch of printing metaIndicatorsBottomConstraint.priority = .init(999) NSLayoutConstraint.activate([ // why is this 4 but the mainContainerTopSelfConstraint constant 8? because this looks more balanced - timelineReasonHStack.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 4), + timelineReasonHStack.topAnchor.constraint(equalTo: statusContainer.topAnchor, constant: 4), timelineReasonLabel.leadingAnchor.constraint(equalTo: contentVStack.leadingAnchor), - timelineReasonHStack.trailingAnchor.constraint(lessThanOrEqualTo: contentView.trailingAnchor, constant: -16), + timelineReasonHStack.trailingAnchor.constraint(lessThanOrEqualTo: statusContainer.trailingAnchor, constant: -16), - mainContainer.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16), - mainContainer.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -16), + mainContainer.leadingAnchor.constraint(equalTo: statusContainer.leadingAnchor, constant: 16), + mainContainer.trailingAnchor.constraint(equalTo: statusContainer.trailingAnchor, constant: -16), - actionsContainer.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16), - actionsContainer.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -16), + actionsContainer.leadingAnchor.constraint(equalTo: statusContainer.leadingAnchor, constant: 16), + actionsContainer.trailingAnchor.constraint(equalTo: statusContainer.trailingAnchor, constant: -16), // yes, this is deliberately 6. 4 looks to cramped, 8 looks uneven - actionsContainer.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -6), + actionsContainer.bottomAnchor.constraint(equalTo: statusContainer.bottomAnchor, constant: -6), metaIndicatorsBottomConstraint, ]) @@ -422,11 +436,57 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell, StatusCollecti // MARK: Configure UI - func updateUI(statusID: String, state: CollapseState) { + private func setContentViewMode(_ mode: ContentViewMode) { + guard mode != contentViewMode else { + return + } + contentViewMode = mode + switch mode { + case .status: + // make the offscreen view transparent so the filtered -> status animation looks better + statusContainer.layer.opacity = 1 + filteredLabel.layer.opacity = 0 + filteredLabel.removeFromSuperview() + contentView.addSubview(statusContainer) + NSLayoutConstraint.activate([ + statusContainer.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + statusContainer.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + statusContainer.topAnchor.constraint(equalTo: contentView.topAnchor), + statusContainer.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), + ]) + case .filtered: + statusContainer.layer.opacity = 0 + filteredLabel.layer.opacity = 1 + statusContainer.removeFromSuperview() + contentView.addSubview(filteredLabel) + NSLayoutConstraint.activate([ + filteredLabel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + filteredLabel.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + filteredLabel.topAnchor.constraint(equalToSystemSpacingBelow: contentView.topAnchor, multiplier: 1), + contentView.bottomAnchor.constraint(equalToSystemSpacingBelow: filteredLabel.bottomAnchor, multiplier: 1), + ]) + } + } + + + func updateUI(statusID: String, state: CollapseState, filterResult: Filterer.Result) { guard var status = mastodonController.persistentContainer.status(for: statusID) else { fatalError() } + switch filterResult { + case .allow: + setContentViewMode(.status) + case .warn(let filterTitle): + let attrStr = NSMutableAttributedString(string: "Filtered: \(filterTitle) ") + let showStr = NSAttributedString(string: "Show", attributes: [.foregroundColor: UIColor.tintColor]) + attrStr.append(showStr) + filteredLabel.attributedText = attrStr + setContentViewMode(.filtered) + case .hide: + fatalError("unreachable") + } + createObservers() self.statusState = state @@ -659,6 +719,13 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell, StatusCollecti } +extension TimelineStatusCollectionViewCell { + private enum ContentViewMode { + case status + case filtered + } +} + extension TimelineStatusCollectionViewCell: UIContextMenuInteractionDelegate { func contextMenuInteraction(_ interaction: UIContextMenuInteraction, configurationForMenuAtLocation location: CGPoint) -> UIContextMenuConfiguration? { return contextMenuConfigurationForAccount(sourceView: interaction.view!)