diff --git a/Tusker.xcodeproj/project.pbxproj b/Tusker.xcodeproj/project.pbxproj index 707fcaf9..21fbcf39 100644 --- a/Tusker.xcodeproj/project.pbxproj +++ b/Tusker.xcodeproj/project.pbxproj @@ -18,6 +18,9 @@ 04DACE8E212CC7CC009840C4 /* ImageCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04DACE8D212CC7CC009840C4 /* ImageCache.swift */; }; 04ED00B121481ED800567C53 /* SteppedProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04ED00B021481ED800567C53 /* SteppedProgressView.swift */; }; D601FA5B29787AB100A8E8B5 /* AccountFollowsListViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D601FA5A29787AB100A8E8B5 /* AccountFollowsListViewController.swift */; }; + D601FA5D297B2E6F00A8E8B5 /* ConversationCollectionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D601FA5C297B2E6F00A8E8B5 /* ConversationCollectionViewController.swift */; }; + D601FA5F297B339100A8E8B5 /* ExpandThreadCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D601FA5E297B339100A8E8B5 /* ExpandThreadCollectionViewCell.swift */; }; + D601FA61297B539E00A8E8B5 /* ConversationMainStatusCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D601FA60297B539E00A8E8B5 /* ConversationMainStatusCollectionViewCell.swift */; }; D6093F9B25BDD4B9004811E6 /* HashtagSearchResultsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6093F9A25BDD4B9004811E6 /* HashtagSearchResultsViewController.swift */; }; D6093FB725BE0CF3004811E6 /* TrendHistoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6093FB625BE0CF3004811E6 /* TrendHistoryView.swift */; }; D60CFFDB24A290BA00D00083 /* SwiftSoup in Frameworks */ = {isa = PBXBuildFile; productRef = D60CFFDA24A290BA00D00083 /* SwiftSoup */; }; @@ -163,8 +166,6 @@ D662AEEF263A3B880082A153 /* PollFinishedTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D662AEED263A3B880082A153 /* PollFinishedTableViewCell.swift */; }; D662AEF0263A3B880082A153 /* PollFinishedTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D662AEEE263A3B880082A153 /* PollFinishedTableViewCell.xib */; }; D662AEF2263A4BE10082A153 /* ComposePollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D662AEF1263A4BE10082A153 /* ComposePollView.swift */; }; - D663625D2135C74800C9CBA2 /* ConversationMainStatusTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D663625C2135C74800C9CBA2 /* ConversationMainStatusTableViewCell.xib */; }; - D663625F2135C75500C9CBA2 /* ConversationMainStatusTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D663625E2135C75500C9CBA2 /* ConversationMainStatusTableViewCell.swift */; }; D663626221360B1900C9CBA2 /* Preferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = D663626121360B1900C9CBA2 /* Preferences.swift */; }; D663626421360D2300C9CBA2 /* AvatarStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = D663626321360D2300C9CBA2 /* AvatarStyle.swift */; }; D663626C21361C6700C9CBA2 /* Account+Preferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = D663626B21361C6700C9CBA2 /* Account+Preferences.swift */; }; @@ -173,7 +174,6 @@ D6676CA527A8D0020052936B /* WebURLFoundationExtras in Frameworks */ = {isa = PBXBuildFile; productRef = D6676CA427A8D0020052936B /* WebURLFoundationExtras */; }; D667E5E12134937B0057A976 /* TimelineStatusTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D667E5E02134937B0057A976 /* TimelineStatusTableViewCell.xib */; }; D667E5F12134D5050057A976 /* UIViewController+Delegates.swift in Sources */ = {isa = PBXBuildFile; fileRef = D667E5F02134D5050057A976 /* UIViewController+Delegates.swift */; }; - D667E5F52135BCD50057A976 /* ConversationTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D667E5F42135BCD50057A976 /* ConversationTableViewController.swift */; }; D667E5F82135C3040057A976 /* Mastodon+Equatable.swift in Sources */ = {isa = PBXBuildFile; fileRef = D667E5F72135C3040057A976 /* Mastodon+Equatable.swift */; }; D66A77BB233838DC0058F1EC /* UIFont+Traits.swift in Sources */ = {isa = PBXBuildFile; fileRef = D66A77BA233838DC0058F1EC /* UIFont+Traits.swift */; }; D66C900B28DAB7FD00217BF2 /* TimelineViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D66C900A28DAB7FD00217BF2 /* TimelineViewController.swift */; }; @@ -293,8 +293,6 @@ D6C693FE2162FEEA007D6A6D /* UIViewController+Children.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C693FD2162FEEA007D6A6D /* UIViewController+Children.swift */; }; D6C7D27D22B6EBF800071952 /* AttachmentsContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C7D27C22B6EBF800071952 /* AttachmentsContainerView.swift */; }; D6C82B4125C5BB7E0017F1E6 /* ExploreViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C82B4025C5BB7E0017F1E6 /* ExploreViewController.swift */; }; - D6C82B5625C5F3F20017F1E6 /* ExpandThreadTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C82B5425C5F3F20017F1E6 /* ExpandThreadTableViewCell.swift */; }; - D6C82B5725C5F3F20017F1E6 /* ExpandThreadTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D6C82B5525C5F3F20017F1E6 /* ExpandThreadTableViewCell.xib */; }; D6C94D872139E62700CB5196 /* LargeImageViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C94D862139E62700CB5196 /* LargeImageViewController.swift */; }; D6C94D892139E6EC00CB5196 /* AttachmentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C94D882139E6EC00CB5196 /* AttachmentView.swift */; }; D6C99FCB24FADC91005C74D3 /* MainComposeTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C99FCA24FADC91005C74D3 /* MainComposeTextView.swift */; }; @@ -416,6 +414,9 @@ 04DACE8D212CC7CC009840C4 /* ImageCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageCache.swift; sourceTree = ""; }; 04ED00B021481ED800567C53 /* SteppedProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SteppedProgressView.swift; sourceTree = ""; }; D601FA5A29787AB100A8E8B5 /* AccountFollowsListViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountFollowsListViewController.swift; sourceTree = ""; }; + D601FA5C297B2E6F00A8E8B5 /* ConversationCollectionViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationCollectionViewController.swift; sourceTree = ""; }; + D601FA5E297B339100A8E8B5 /* ExpandThreadCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExpandThreadCollectionViewCell.swift; sourceTree = ""; }; + D601FA60297B539E00A8E8B5 /* ConversationMainStatusCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationMainStatusCollectionViewCell.swift; sourceTree = ""; }; D6093F9A25BDD4B9004811E6 /* HashtagSearchResultsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HashtagSearchResultsViewController.swift; sourceTree = ""; }; D6093FB625BE0CF3004811E6 /* TrendHistoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrendHistoryView.swift; sourceTree = ""; }; D60D2B8123844C71001B87A3 /* BaseStatusTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BaseStatusTableViewCell.swift; sourceTree = ""; }; @@ -561,8 +562,6 @@ D662AEED263A3B880082A153 /* PollFinishedTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollFinishedTableViewCell.swift; sourceTree = ""; }; D662AEEE263A3B880082A153 /* PollFinishedTableViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = PollFinishedTableViewCell.xib; sourceTree = ""; }; D662AEF1263A4BE10082A153 /* ComposePollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposePollView.swift; sourceTree = ""; }; - D663625C2135C74800C9CBA2 /* ConversationMainStatusTableViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = ConversationMainStatusTableViewCell.xib; sourceTree = ""; }; - D663625E2135C75500C9CBA2 /* ConversationMainStatusTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationMainStatusTableViewCell.swift; sourceTree = ""; }; D663626121360B1900C9CBA2 /* Preferences.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Preferences.swift; sourceTree = ""; }; D663626321360D2300C9CBA2 /* AvatarStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AvatarStyle.swift; sourceTree = ""; }; D663626B21361C6700C9CBA2 /* Account+Preferences.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Account+Preferences.swift"; sourceTree = ""; }; @@ -570,7 +569,6 @@ D6674AE923341F7600E8DF94 /* AppShortcutItems.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppShortcutItems.swift; sourceTree = ""; }; D667E5E02134937B0057A976 /* TimelineStatusTableViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = TimelineStatusTableViewCell.xib; sourceTree = ""; }; D667E5F02134D5050057A976 /* UIViewController+Delegates.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIViewController+Delegates.swift"; sourceTree = ""; }; - D667E5F42135BCD50057A976 /* ConversationTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationTableViewController.swift; sourceTree = ""; }; D667E5F72135C3040057A976 /* Mastodon+Equatable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Mastodon+Equatable.swift"; sourceTree = ""; }; D66A77BA233838DC0058F1EC /* UIFont+Traits.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIFont+Traits.swift"; sourceTree = ""; }; D66C900A28DAB7FD00217BF2 /* TimelineViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineViewController.swift; sourceTree = ""; }; @@ -691,8 +689,6 @@ D6C693FD2162FEEA007D6A6D /* UIViewController+Children.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIViewController+Children.swift"; sourceTree = ""; }; D6C7D27C22B6EBF800071952 /* AttachmentsContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachmentsContainerView.swift; sourceTree = ""; }; D6C82B4025C5BB7E0017F1E6 /* ExploreViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExploreViewController.swift; sourceTree = ""; }; - D6C82B5425C5F3F20017F1E6 /* ExpandThreadTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExpandThreadTableViewCell.swift; sourceTree = ""; }; - D6C82B5525C5F3F20017F1E6 /* ExpandThreadTableViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = ExpandThreadTableViewCell.xib; sourceTree = ""; }; D6C94D862139E62700CB5196 /* LargeImageViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LargeImageViewController.swift; sourceTree = ""; }; D6C94D882139E6EC00CB5196 /* AttachmentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachmentView.swift; sourceTree = ""; }; D6C99FCA24FADC91005C74D3 /* MainComposeTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainComposeTextView.swift; sourceTree = ""; }; @@ -1049,9 +1045,8 @@ isa = PBXGroup; children = ( D65B4B6329771EFF00DABDFB /* ConversationViewController.swift */, - D667E5F42135BCD50057A976 /* ConversationTableViewController.swift */, - D6C82B5425C5F3F20017F1E6 /* ExpandThreadTableViewCell.swift */, - D6C82B5525C5F3F20017F1E6 /* ExpandThreadTableViewCell.xib */, + D601FA5C297B2E6F00A8E8B5 /* ConversationCollectionViewController.swift */, + D601FA5E297B339100A8E8B5 /* ExpandThreadCollectionViewCell.swift */, ); path = Conversation; sourceTree = ""; @@ -1130,13 +1125,12 @@ D60D2B8123844C71001B87A3 /* BaseStatusTableViewCell.swift */, D667E5E02134937B0057A976 /* TimelineStatusTableViewCell.xib */, D6BED173212667E900F02DA0 /* TimelineStatusTableViewCell.swift */, - D663625C2135C74800C9CBA2 /* ConversationMainStatusTableViewCell.xib */, - D663625E2135C75500C9CBA2 /* ConversationMainStatusTableViewCell.swift */, D6C1B2072545D1EC00DAAA66 /* StatusCardView.swift */, D6EE63FA2551F7F60065485C /* StatusCollapseButton.swift */, D62E9986279D094F00C26176 /* StatusMetaIndicatorsView.swift */, D61ABEF528EE74D400B29151 /* StatusCollectionViewCell.swift */, D6ADB6E928E91C30009924AB /* TimelineStatusCollectionViewCell.swift */, + D601FA60297B539E00A8E8B5 /* ConversationMainStatusCollectionViewCell.swift */, D6ADB6EB28EA73CB009924AB /* StatusContentContainer.swift */, ); path = Status; @@ -1804,12 +1798,10 @@ D6A3BC8B2321F79B00FD64D5 /* AccountTableViewCell.xib in Resources */, D6A3BC7D232195C600FD64D5 /* ActionNotificationGroupTableViewCell.xib in Resources */, D6412B0B24B0D4C600F5412E /* ProfileHeaderView.xib in Resources */, - D6C82B5725C5F3F20017F1E6 /* ExpandThreadTableViewCell.xib in Resources */, D6D4DDDA212518A200E1C4BB /* LaunchScreen.storyboard in Resources */, D6E57FA325C26FAB00341037 /* Localizable.stringsdict in Resources */, D6B053AC23BD2F1400A066FA /* AssetCollectionViewCell.xib in Resources */, D6D4DDD7212518A200E1C4BB /* Assets.xcassets in Resources */, - D663625D2135C74800C9CBA2 /* ConversationMainStatusTableViewCell.xib in Resources */, D640D76922BAF5E6004FBE69 /* DomainBlocks.plist in Resources */, D6289E84217B795D0003D1D7 /* LargeImageViewController.xib in Resources */, D626493D23C1000300612E6E /* AlbumTableViewCell.xib in Resources */, @@ -1934,7 +1926,6 @@ D64AAE9126C80DC600FC57FB /* ToastView.swift in Sources */, D62275AA24F1E01C00B82A16 /* ComposeTextView.swift in Sources */, 0450531F22B0097E00100BA2 /* Timline+UI.swift in Sources */, - D667E5F52135BCD50057A976 /* ConversationTableViewController.swift in Sources */, D6C7D27D22B6EBF800071952 /* AttachmentsContainerView.swift in Sources */, D61F75942936F0DA00C0B37F /* FollowedHashtag.swift in Sources */, D63CC7122911F57C000E19DE /* StatusBarTappableViewController.swift in Sources */, @@ -2086,7 +2077,6 @@ D6333B792139AEFD00CE884A /* Date+TimeAgo.swift in Sources */, D6D706A029466649000827ED /* ScrollingSegmentedControl.swift in Sources */, D653F411267D1E32004E32B1 /* DiffableTimelineLikeTableViewController.swift in Sources */, - D6C82B5625C5F3F20017F1E6 /* ExpandThreadTableViewCell.swift in Sources */, D686BBE324FBF8110068E6AA /* WrappedProgressView.swift in Sources */, D620483423D3801D008A63EF /* LinkTextView.swift in Sources */, D61F75882932DB6000C0B37F /* StatusSwipeAction.swift in Sources */, @@ -2105,7 +2095,9 @@ D61AC1D8232EA42D00C54D2D /* InstanceTableViewCell.swift in Sources */, D6D3F4C424FDB6B700EC4A6A /* View+ConditionalModifier.swift in Sources */, D6B8DB342182A59300424AF7 /* UIAlertController+Visibility.swift in Sources */, + D601FA61297B539E00A8E8B5 /* ConversationMainStatusCollectionViewCell.swift in Sources */, D67C57AD21E265FC00C3118B /* LargeAccountDetailView.swift in Sources */, + D601FA5F297B339100A8E8B5 /* ExpandThreadCollectionViewCell.swift in Sources */, D6114E1327F89B440080E273 /* TrendingLinkTableViewCell.swift in Sources */, D6262C9A28D01C4B00390C1F /* LoadingTableViewCell.swift in Sources */, D68C2AE325869BAB00548EFF /* AuxiliarySceneDelegate.swift in Sources */, @@ -2179,12 +2171,12 @@ D677284E24ECC01D00C732D3 /* Draft.swift in Sources */, D667E5F12134D5050057A976 /* UIViewController+Delegates.swift in Sources */, D6BC8748219738E1006163F1 /* EnhancedTableViewController.swift in Sources */, - D663625F2135C75500C9CBA2 /* ConversationMainStatusTableViewCell.swift in Sources */, D6E426B325337C7000C02E1C /* CustomEmojiImageView.swift in Sources */, D6D4DDD0212518A000E1C4BB /* AppDelegate.swift in Sources */, D61F75B3293BD89C00C0B37F /* UpdateFilterService.swift in Sources */, D65B4B6A297777D900DABDFB /* StatusNotFoundView.swift in Sources */, D6412B0924B0291E00F5412E /* MyProfileViewController.swift in Sources */, + D601FA5D297B2E6F00A8E8B5 /* ConversationCollectionViewController.swift in Sources */, D6D12B56292D57E800D528E1 /* AccountCollectionViewCell.swift in Sources */, D6A6C10525B6138A00298D0F /* StatusTablePrefetching.swift in Sources */, D6C82B4125C5BB7E0017F1E6 /* ExploreViewController.swift in Sources */, diff --git a/Tusker/Screens/Conversation/ConversationCollectionViewController.swift b/Tusker/Screens/Conversation/ConversationCollectionViewController.swift new file mode 100644 index 00000000..5bd35b1f --- /dev/null +++ b/Tusker/Screens/Conversation/ConversationCollectionViewController.swift @@ -0,0 +1,406 @@ +// +// ConversationCollectionViewController.swift +// Tusker +// +// Created by Shadowfacts on 8/28/18. +// Copyright © 2018 Shadowfacts. All rights reserved. +// + +import UIKit +import Pachyderm + +class ConversationNode { + let status: StatusMO + var children: [ConversationNode] + + init(status: StatusMO) { + self.status = status + self.children = [] + } +} + +class ConversationCollectionViewController: UIViewController, CollectionViewController { + + private let mastodonController: MastodonController + private let mainStatusID: String + private let mainStatusState: CollapseState + var statusIDToScrollToOnLoad: String + var showStatusesAutomatically = false + + var collectionView: UICollectionView! { + view as? UICollectionView + } + private var dataSource: UICollectionViewDiffableDataSource! + + init(for mainStatusID: String, state: CollapseState, mastodonController: MastodonController) { + self.mainStatusID = mainStatusID + self.mainStatusState = state + self.statusIDToScrollToOnLoad = mainStatusID + self.mastodonController = mastodonController + + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func loadView() { + var config = UICollectionLayoutListConfiguration(appearance: .plain) + config.backgroundColor = .secondarySystemBackground + 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, sectionConfig in + let rowsInSection = self.collectionView.numberOfItems(inSection: indexPath.section) + let lastInSection = indexPath.row == rowsInSection - 1 + var config = sectionConfig + config.topSeparatorVisibility = .hidden + config.bottomSeparatorVisibility = lastInSection ? .visible : .hidden + config.bottomSeparatorInsets = TimelineStatusCollectionViewCell.separatorInsets + return config + } + // we're not using contenetInsetsReference = .readableContent here because it always insets the cells even if + // the collection view's actual width is narrow enough to fit in the readable width, resulting in a bit of the + // background color always peaking through the edges + let layout = UICollectionViewCompositionalLayout.list(using: config) + view = UICollectionView(frame: .zero, collectionViewLayout: layout) + // something about the autoresizing mask breaks resizing the vc + view.translatesAutoresizingMaskIntoConstraints = false + collectionView.delegate = self + collectionView.dragDelegate = self + collectionView.allowsFocus = true + + dataSource = createDataSource() + } + + private func createDataSource() -> UICollectionViewDiffableDataSource { + let statusCell = UICollectionView.CellRegistration { [unowned self] cell, indexPath, item in + cell.delegate = self + cell.showStatusAutomatically = self.showStatusesAutomatically + cell.showReplyIndicator = false + cell.updateUI(statusID: item.0, state: item.1, filterResult: .allow, precomputedContent: nil) + cell.setShowThreadLinks(prev: item.2, next: item.3) + } + let mainStatusCell = UICollectionView.CellRegistration { [unowned self] cell, indexPath, item in + cell.delegate = self + cell.showStatusAutomatically = self.showStatusesAutomatically + cell.updateUI(statusID: item.0, state: item.1) + cell.setShowThreadLinks(prev: item.2, next: false) + } + let expandThreadCell = UICollectionView.CellRegistration { cell, indexPath, item in + cell.updateUI(childThreads: item.0, inline: item.1) + } + return UICollectionViewDiffableDataSource(collectionView: collectionView) { [unowned self] collectionView, indexPath, itemIdentifier in + switch itemIdentifier { + case let .status(id: id, state: state, prevLink: prevLink, nextLink: nextLink): + if id == self.mainStatusID { + return collectionView.dequeueConfiguredReusableCell(using: mainStatusCell, for: indexPath, item: (id, state, prevLink)) + } else { + return collectionView.dequeueConfiguredReusableCell(using: statusCell, for: indexPath, item: (id, state, prevLink, nextLink)) + } + case .expandThread(childThreads: let childThreads, inline: let inline): + return collectionView.dequeueConfiguredReusableCell(using: expandThreadCell, for: indexPath, item: (childThreads, inline)) + } + } + } + + func addMainStatus(_ status: StatusMO) { + loadViewIfNeeded() + + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([.statuses]) + let mainStatusItem = Item.status(id: mainStatusID, state: mainStatusState, prevLink: status.inReplyToID != nil, nextLink: false) + snapshot.appendItems([mainStatusItem], toSection: .statuses) + dataSource.apply(snapshot, animatingDifferences: false) + } + + func addContext(_ context: ConversationContext, for mainStatus: StatusMO) async { + let parentIDs = getDirectParents(inReplyTo: mainStatus.inReplyToID, from: context.ancestors) + let parentStatuses = context.ancestors.filter { parentIDs.contains($0.id) } + + await mastodonController.persistentContainer.addAll(statuses: parentStatuses + context.descendants) + + var snapshot = dataSource.snapshot() + let mainStatusItem = Item.status(id: mainStatusID, state: mainStatusState, prevLink: mainStatus.inReplyToID != nil, nextLink: false) + let parentItems = parentIDs.enumerated().map { index, id in + Item.status(id: id, state: .unknown, prevLink: index > 0, nextLink: true) + } + snapshot.insertItems(parentItems, beforeItem: mainStatusItem) + snapshot.reloadItems([mainStatusItem]) + + // fetch all descendant status managed objects + let descendantIDs = context.descendants.map(\.id) + let request = StatusMO.fetchRequest() + request.predicate = NSPredicate(format: "id IN %@", descendantIDs) + + if let descendants = try? mastodonController.persistentContainer.viewContext.fetch(request) { + // convert array of descendant statuses into tree of sub-threads + let childThreads = self.getDescendantThreads(mainStatus: mainStatus, descendants: descendants) + + // convert sub-threads into items for section and add to snapshot + self.addFlattenedChildThreadsToSnapshot(childThreads, mainStatus: mainStatus, snapshot: &snapshot) + } + + self.dataSource.apply(snapshot, animatingDifferences: false) { + let item: Item + let position: UICollectionView.ScrollPosition + if self.statusIDToScrollToOnLoad == self.mainStatusID { + item = mainStatusItem + position = .centeredVertically + } else { + item = snapshot.itemIdentifiers.first { + if case .status(id: self.statusIDToScrollToOnLoad, _, _, _) = $0 { + return true + } else { + return false + } + }! + position = .top + } + // ensure that the status is on-screen after newly loaded statuses are added + // todo: should this not happen if the user has already started scrolling (e.g. because the main status is very long)? + if let indexPath = self.dataSource.indexPath(for: item) { + self.collectionView.scrollToItem(at: indexPath, at: position, animated: false) + } + } + } + + private func getDirectParents(inReplyTo inReplyToID: String?, from statuses: [Status]) -> [String] { + var statuses = statuses + var parents = [String]() + + var parentID: String? = inReplyToID + + while parentID != nil, let parentIndex = statuses.firstIndex(where: { $0.id == parentID }) { + let parentStatus = statuses.remove(at: parentIndex) + parents.insert(parentStatus.id, at: 0) + parentID = parentStatus.inReplyToID + } + + return parents + } + + private func getDescendantThreads(mainStatus: StatusMO, descendants: [StatusMO]) -> [ConversationNode] { + var descendants = descendants + + func removeAllInReplyTo(id: String) -> [StatusMO] { + let statuses = descendants.filter { $0.inReplyToID == id } + descendants.removeAll { $0.inReplyToID == id } + return statuses + } + + var nodes: [String: ConversationNode] = [ + mainStatus.id: ConversationNode(status: mainStatus) + ] + + var idsToCheck = [mainStatusID] + + while !idsToCheck.isEmpty { + let inReplyToID = idsToCheck.removeFirst() + let nodeForID = nodes[inReplyToID]! + + let inReply = removeAllInReplyTo(id: inReplyToID) + for reply in inReply { + idsToCheck.append(reply.id) + + let replyNode = ConversationNode(status: reply) + nodes[reply.id] = replyNode + + nodeForID.children.append(replyNode) + } + } + + return nodes[mainStatusID]!.children + } + + private func addFlattenedChildThreadsToSnapshot(_ childThreads: [ConversationNode], mainStatus: StatusMO, snapshot: inout NSDiffableDataSourceSnapshot) { + var childThreads = childThreads + + // child threads by the same author as the main status come first + let pivotIndex = childThreads.partition(by: { $0.status.account.id != mainStatus.account.id }) + + // within each group, child threads are sorted chronologically + childThreads[0.. Bool { + switch (lhs, rhs) { + case let (.status(id: a, state: _, prevLink: aPrev, nextLink: aNext), .status(id: b, state: _, prevLink: bPrev, nextLink: bNext)): + return a == b && aPrev == bPrev && aNext == bNext + case let (.expandThread(childThreads: a, inline: aInline), .expandThread(childThreads: b, inline: bInline)): + return a.count == b.count && zip(a, b).allSatisfy { $0.status.id == $1.status.id } && aInline == bInline + default: + return false + } + } + + func hash(into hasher: inout Hasher) { + switch self { + case let .status(id: id, state: _, prevLink: prevLink, nextLink: nextLink): + hasher.combine(0) + hasher.combine(id) + hasher.combine(prevLink) + hasher.combine(nextLink) + case .expandThread(childThreads: let childThreads, inline: let inline): + hasher.combine(1) + for thread in childThreads { + hasher.combine(thread.status.id) + } + hasher.combine(inline) + } + } + } +} + +extension ConversationCollectionViewController: UICollectionViewDelegate { + func collectionView(_ collectionView: UICollectionView, shouldSelectItemAt indexPath: IndexPath) -> Bool { + if case .status(id: let id, _, _, _) = dataSource.itemIdentifier(for: indexPath), + id == mainStatusID { + return false + } else { + return true + } + } + + func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + switch dataSource.itemIdentifier(for: indexPath) { + case nil: + break + case .status(id: let id, state: let state, _, _): + selected(status: id, state: state.copy()) + case .expandThread(childThreads: let childThreads, inline: _): + if case .status(id: let id, state: let state, _, _) = dataSource.itemIdentifier(for: IndexPath(row: indexPath.row - 1, section: indexPath.section)) { + // todo: it would be nice to avoid re-fetching the context here, since we should have all the necessary information already + let conv = ConversationViewController(for: id, state: state.copy(), mastodonController: mastodonController) + conv.statusIDToScrollToOnLoad = childThreads.first!.status.id + conv.showStatusesAutomatically = showStatusesAutomatically + show(conv) + } + } + } + + 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) + } +} + +extension ConversationCollectionViewController: UICollectionViewDragDelegate { + func collectionView(_ collectionView: UICollectionView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath) -> [UIDragItem] { + return (collectionView.cellForItem(at: indexPath) as? TimelineStatusCollectionViewCell)?.dragItemsForBeginning(session: session) ?? [] + } +} + +extension ConversationCollectionViewController: TuskerNavigationDelegate { + var apiController: MastodonController! { mastodonController } +} + +extension ConversationCollectionViewController: MenuActionProvider { +} + +extension ConversationCollectionViewController: 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) { + // todo: support filtering in conversations + } +} + +extension ConversationCollectionViewController: TabBarScrollableViewController { + func tabBarScrollToTop() { + collectionView.scrollToTop() + } +} + +extension ConversationCollectionViewController: StatusBarTappableViewController { + func handleStatusBarTapped(xPosition: CGFloat) -> StatusBarTapActionResult { + collectionView.scrollToTop() + return .stop + } +} diff --git a/Tusker/Screens/Conversation/ConversationTableViewController.swift b/Tusker/Screens/Conversation/ConversationTableViewController.swift deleted file mode 100644 index b03e042e..00000000 --- a/Tusker/Screens/Conversation/ConversationTableViewController.swift +++ /dev/null @@ -1,381 +0,0 @@ -// -// ConversationTableViewController.swift -// Tusker -// -// Created by Shadowfacts on 8/28/18. -// Copyright © 2018 Shadowfacts. All rights reserved. -// - -import UIKit -import Pachyderm -import CoreData - -class ConversationNode { - let status: StatusMO - var children: [ConversationNode] - - init(status: StatusMO) { - self.status = status - self.children = [] - } -} - -class ConversationTableViewController: EnhancedTableViewController { - - weak var mastodonController: MastodonController! - - let mainStatusID: String - let mainStatusState: CollapseState - var statusIDToScrollToOnLoad: String - private(set) var dataSource: UITableViewDiffableDataSource! - - var showStatusesAutomatically = false - - init(for mainStatusID: String, state: CollapseState, mastodonController: MastodonController) { - self.mainStatusID = mainStatusID - self.mainStatusState = state - self.statusIDToScrollToOnLoad = mainStatusID - self.mastodonController = mastodonController - - super.init(style: .plain) - - dragEnabled = true - } - - required init?(coder aDecoder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func viewDidLoad() { - super.viewDidLoad() - - tableView.delegate = self - tableView.dataSource = self - tableView.prefetchDataSource = self - - tableView.register(UINib(nibName: "TimelineStatusTableViewCell", bundle: nil), forCellReuseIdentifier: "statusCell") - tableView.register(UINib(nibName: "ConversationMainStatusTableViewCell", bundle: nil), forCellReuseIdentifier: "mainStatusCell") - tableView.register(UINib(nibName: "ExpandThreadTableViewCell", bundle: .main), forCellReuseIdentifier: "expandThreadCell") - - tableView.allowsFocus = true - tableView.backgroundColor = .secondarySystemBackground - // separators are disabled on the table view so we can re-add them ourselves - // so they're not inserted in between statuses in the ame sub-thread - tableView.separatorStyle = .none - tableView.cellLayoutMarginsFollowReadableWidth = UIDevice.current.userInterfaceIdiom == .pad || UIDevice.current.userInterfaceIdiom == .mac - - dataSource = UITableViewDiffableDataSource(tableView: tableView, cellProvider: { (tableView, indexPath, item) -> UITableViewCell? in - switch item { - case let .status(id: id, state: state): - let rowsInSection = self.dataSource.tableView(tableView, numberOfRowsInSection: indexPath.section) - let firstInSection = indexPath.row == 0 - let lastInSection = indexPath.row == rowsInSection - 1 - - let identifier = id == self.mainStatusID ? "mainStatusCell" : "statusCell" - let cell = tableView.dequeueReusableCell(withIdentifier: identifier, for: indexPath) as! BaseStatusTableViewCell - - if id == self.mainStatusID { - cell.selectionStyle = .none - } - - cell.delegate = self - cell.showStatusAutomatically = self.showStatusesAutomatically - - if let cell = cell as? TimelineStatusTableViewCell { - cell.showReplyIndicator = false - } - - cell.updateUI(statusID: id, state: state) - - cell.setShowThreadLinks(prev: !firstInSection, next: !lastInSection) - if lastInSection { - if cell.viewWithTag(ViewTags.conversationBottomSeparator) == nil { - let separator = UIView() - separator.tag = ViewTags.conversationBottomSeparator - separator.translatesAutoresizingMaskIntoConstraints = false - separator.backgroundColor = tableView.separatorColor - cell.addSubview(separator) - NSLayoutConstraint.activate([ - separator.heightAnchor.constraint(equalToConstant: 0.5), - separator.bottomAnchor.constraint(equalTo: cell.bottomAnchor), - separator.leftAnchor.constraint(equalTo: cell.leftAnchor, constant: cell.separatorInset.left), - separator.rightAnchor.constraint(equalTo: cell.rightAnchor), - ]) - } - } else { - cell.viewWithTag(ViewTags.conversationBottomSeparator)?.removeFromSuperview() - } - - return cell - - case let .expandThread(childThreads: childThreads, inline: inline): - let cell = tableView.dequeueReusableCell(withIdentifier: "expandThreadCell", for: indexPath) as! ExpandThreadTableViewCell - cell.updateUI(childThreads: childThreads, inline: inline) - return cell - } - }) - } - - func addMainStatus(_ status: StatusMO) { - loadViewIfNeeded() - - let mainStatusItem = Item.status(id: mainStatusID, state: mainStatusState) - var snapshot = NSDiffableDataSourceSnapshot() - snapshot.appendSections([.statuses]) - snapshot.appendItems([mainStatusItem], toSection: .statuses) - dataSource.apply(snapshot, animatingDifferences: false) - } - - func addContext(_ context: ConversationContext, for mainStatus: StatusMO) async { - let parentIDs = self.getDirectParents(inReplyTo: mainStatus.inReplyToID, from: context.ancestors) - let parentStatuses = context.ancestors.filter { parentIDs.contains($0.id) } - - await mastodonController.persistentContainer.addAll(statuses: parentStatuses + context.descendants) - - let mainStatusItem = Item.status(id: mainStatusID, state: mainStatusState) - - var snapshot = NSDiffableDataSourceSnapshot() - snapshot.appendSections([.statuses]) - snapshot.appendItems([mainStatusItem], toSection: .statuses) - snapshot.insertItems(parentIDs.map { .status(id: $0, state: .unknown) }, beforeItem: mainStatusItem) - - // fetch all descendant status managed objects - let descendantIDs = context.descendants.map(\.id) - let request: NSFetchRequest = StatusMO.fetchRequest() - request.predicate = NSPredicate(format: "id in %@", descendantIDs) - - if let descendants = try? self.mastodonController.persistentContainer.viewContext.fetch(request) { - // convert array of descendant statuses into tree of sub-threads - let childThreads = self.getDescendantThreads(mainStatus: mainStatus, descendants: descendants) - - // convert sub-threads into items for section and add to snapshot - self.addFlattenedChildThreadsToSnapshot(childThreads, mainStatus: mainStatus, snapshot: &snapshot) - } - - self.dataSource.apply(snapshot, animatingDifferences: false) { - let item: Item - let position: UITableView.ScrollPosition - if self.statusIDToScrollToOnLoad == self.mainStatusID { - item = mainStatusItem - position = .middle - } else { - item = Item.status(id: self.statusIDToScrollToOnLoad, state: .unknown) - position = .top - } - // ensure that the status is on-screen after newly loaded statuses are added - // todo: should this not happen if the user has already started scrolling (e.g. because the main status is very long)? - if let indexPath = self.dataSource.indexPath(for: item) { - self.tableView.scrollToRow(at: indexPath, at: position, animated: false) - } - } - } - - private func getDirectParents(inReplyTo inReplyToID: String?, from statuses: [Status]) -> [String] { - var statuses = statuses - var parents = [String]() - - var parentID: String? = inReplyToID - - while parentID != nil, let parentIndex = statuses.firstIndex(where: { $0.id == parentID }) { - let parentStatus = statuses.remove(at: parentIndex) - parents.insert(parentStatus.id, at: 0) - parentID = parentStatus.inReplyToID - } - - return parents - } - - private func getDescendantThreads(mainStatus: StatusMO, descendants: [StatusMO]) -> [ConversationNode] { - var descendants = descendants - - func removeAllInReplyTo(id: String) -> [StatusMO] { - let statuses = descendants.filter { $0.inReplyToID == id } - descendants.removeAll { $0.inReplyToID == id } - return statuses - } - - var nodes: [String: ConversationNode] = [ - mainStatus.id: ConversationNode(status: mainStatus) - ] - - var idsToCheck = [mainStatusID] - - while !idsToCheck.isEmpty { - let inReplyToID = idsToCheck.removeFirst() - let nodeForID = nodes[inReplyToID]! - - let inReply = removeAllInReplyTo(id: inReplyToID) - for reply in inReply { - idsToCheck.append(reply.id) - - let replyNode = ConversationNode(status: reply) - nodes[reply.id] = replyNode - - nodeForID.children.append(replyNode) - } - } - - return nodes[mainStatusID]!.children - } - - private func addFlattenedChildThreadsToSnapshot(_ childThreads: [ConversationNode], mainStatus: StatusMO, snapshot: inout NSDiffableDataSourceSnapshot) { - var childThreads = childThreads - - // child threads by the same author as the main status come first - let pivotIndex = childThreads.partition(by: { $0.status.account.id != mainStatus.account.id }) - - // within each group, child threads are sorted chronologically - childThreads[0.. (id: String, state: CollapseState)? { - return self.dataSource.itemIdentifier(for: indexPath).flatMap { (item) in - switch item { - case let .status(id: id, state: state): - return (id: id, state: state) - default: - return nil - } - } - } - - // MARK: - Table view delegate - - override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - if case let .expandThread(childThreads: childThreads, inline: _) = dataSource.itemIdentifier(for: indexPath), - case let .status(id: id, state: state) = dataSource.itemIdentifier(for: IndexPath(row: indexPath.row - 1, section: indexPath.section)) { - let conv = ConversationViewController(for: id, state: state, mastodonController: mastodonController) - conv.statusIDToScrollToOnLoad = childThreads.first!.status.id - conv.showStatusesAutomatically = showStatusesAutomatically - show(conv) - } else { - super.tableView(tableView, didSelectRowAt: indexPath) - } - } - - override func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool { - return true - } - - override func tableView(_ tableView: UITableView, leadingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? { - return (tableView.cellForRow(at: indexPath) as? TableViewSwipeActionProvider)?.leadingSwipeActionsConfiguration() - } - - override func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? { - return (tableView.cellForRow(at: indexPath) as? TableViewSwipeActionProvider)?.trailingSwipeActionsConfiguration() - } - - func updateVisibleCellCollapseState() { - let snapshot = dataSource.snapshot() - for case let .status(id: _, state: state) in snapshot.itemIdentifiers where state.collapsible == true { - state.collapsed = !showStatusesAutomatically - } - - for cell in tableView.visibleCells { - guard let cell = cell as? BaseStatusTableViewCell, - cell.collapsible else { continue } - cell.showStatusAutomatically = showStatusesAutomatically - cell.setCollapsed(!showStatusesAutomatically, animated: false) - } - - // recalculate cell heights - tableView.beginUpdates() - tableView.endUpdates() - } -} - -extension ConversationTableViewController { - enum Section: Hashable { - case statuses - case childThread(firstStatusID: String) - } - enum Item: Hashable { - case status(id: String, state: CollapseState) - case expandThread(childThreads: [ConversationNode], inline: Bool) - - static func == (lhs: Item, rhs: Item) -> Bool { - switch (lhs, rhs) { - case let (.status(id: a, state: _), .status(id: b, state: _)): - return a == b - case let (.expandThread(childThreads: a, inline: aInline), .expandThread(childThreads: b, inline: bInline)): - return zip(a, b).allSatisfy { $0.status.id == $1.status.id } && aInline == bInline - default: - return false - } - } - - func hash(into hasher: inout Hasher) { - switch self { - case let .status(id: id, state: _): - hasher.combine("status") - hasher.combine(id) - case let .expandThread(childThreads: children, inline: inline): - hasher.combine("expandThread") - hasher.combine(children.map(\.status.id)) - hasher.combine(inline) - } - } - } -} - -extension ConversationTableViewController: TuskerNavigationDelegate { - var apiController: MastodonController! { mastodonController } - - func conversation(mainStatusID: String, state: CollapseState) -> ConversationViewController { - let vc = ConversationViewController(for: mainStatusID, state: state, mastodonController: mastodonController) - // transfer show statuses automatically state when showing new conversation - vc.showStatusesAutomatically = self.showStatusesAutomatically - return vc - } -} - -extension ConversationTableViewController: MenuActionProvider { -} - -extension ConversationTableViewController: StatusTableViewCellDelegate { - func statusCellCollapsedStateChanged(_ cell: BaseStatusTableViewCell) { - // causes the table view to recalculate the cell heights - tableView.beginUpdates() - tableView.endUpdates() - } -} - -extension ConversationTableViewController: UITableViewDataSourcePrefetching, StatusTablePrefetching { - func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) { - let ids: [String] = indexPaths.compactMap { item(for: $0)?.id } - prefetchStatuses(with: ids) - } -} - -extension ConversationTableViewController: ToastableViewController { -} diff --git a/Tusker/Screens/Conversation/ConversationViewController.swift b/Tusker/Screens/Conversation/ConversationViewController.swift index 5dc955e8..405a5113 100644 --- a/Tusker/Screens/Conversation/ConversationViewController.swift +++ b/Tusker/Screens/Conversation/ConversationViewController.swift @@ -171,7 +171,7 @@ class ConversationViewController: UIViewController { @MainActor private func mainStatusLoaded(_ mainStatus: StatusMO) async { - let vc = ConversationTableViewController(for: mainStatusID, state: mainStatusState, mastodonController: mastodonController) + let vc = ConversationCollectionViewController(for: mainStatusID, state: mainStatusState, mastodonController: mastodonController) vc.statusIDToScrollToOnLoad = statusIDToScrollToOnLoad vc.showStatusesAutomatically = showStatusesAutomatically vc.addMainStatus(mainStatus) @@ -244,7 +244,7 @@ extension ConversationViewController { enum State { case unloaded case loading(UIActivityIndicatorView) - case displaying(ConversationTableViewController) + case displaying(ConversationCollectionViewController) case notFound } } diff --git a/Tusker/Screens/Conversation/ExpandThreadTableViewCell.swift b/Tusker/Screens/Conversation/ExpandThreadCollectionViewCell.swift similarity index 65% rename from Tusker/Screens/Conversation/ExpandThreadTableViewCell.swift rename to Tusker/Screens/Conversation/ExpandThreadCollectionViewCell.swift index 3bdc9cd3..5a40676b 100644 --- a/Tusker/Screens/Conversation/ExpandThreadTableViewCell.swift +++ b/Tusker/Screens/Conversation/ExpandThreadCollectionViewCell.swift @@ -1,5 +1,5 @@ // -// ExpandThreadTableViewCell.swift +// ExpandThreadCollectionViewCell.swift // Tusker // // Created by Shadowfacts on 1/30/21. @@ -8,62 +8,94 @@ import UIKit -class ExpandThreadTableViewCell: UITableViewCell { - - @IBOutlet weak var stackViewLeadingConstraint: NSLayoutConstraint! - @IBOutlet weak var avatarContainerView: UIView! - @IBOutlet weak var avatarContainerWidthConstraint: NSLayoutConstraint! - @IBOutlet weak var replyCountLabel: UILabel! +class ExpandThreadCollectionViewCell: UICollectionViewCell { + + private var avatarContainerView: UIView! + private var avatarContainerWidthConstraint: NSLayoutConstraint! + + private var replyCountLabel: UILabel! + + private var hStack: UIStackView! + private var stackViewLeadingConstraint: NSLayoutConstraint! + private var threadLinkView: UIView! private var threadLinkViewFullHeightConstraint: NSLayoutConstraint! private var threadLinkViewShortHeightConstraint: NSLayoutConstraint! + private var avatarImageViews: [UIImageView] = [] - private var avatarRequests: [ImageCache.Request] = [] - - override func awakeFromNib() { - super.awakeFromNib() + override init(frame: CGRect) { + super.init(frame: frame) + + avatarContainerView = UIView() + avatarContainerView.backgroundColor = .clear + avatarContainerWidthConstraint = avatarContainerView.widthAnchor.constraint(equalToConstant: 100) + + replyCountLabel = UILabel() + replyCountLabel.textColor = .tintColor + replyCountLabel.font = .preferredFont(forTextStyle: .body) + replyCountLabel.adjustsFontForContentSizeCategory = true + + hStack = UIStackView(arrangedSubviews: [ + avatarContainerView, + replyCountLabel, + ]) + hStack.spacing = 8 + hStack.alignment = .center + hStack.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(hStack) + stackViewLeadingConstraint = hStack.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16) + + // TODO: separator threadLinkView = UIView() - threadLinkView.translatesAutoresizingMaskIntoConstraints = false threadLinkView.backgroundColor = .tintColor.withAlphaComponent(0.5) threadLinkView.layer.cornerRadius = 2.5 + threadLinkView.translatesAutoresizingMaskIntoConstraints = false contentView.addSubview(threadLinkView) threadLinkViewFullHeightConstraint = threadLinkView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor) threadLinkViewShortHeightConstraint = threadLinkView.bottomAnchor.constraint(equalTo: avatarContainerView.topAnchor, constant: -2) + NSLayoutConstraint.activate([ + avatarContainerWidthConstraint, + avatarContainerView.heightAnchor.constraint(equalToConstant: 32), + + stackViewLeadingConstraint, + hStack.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -16), + hStack.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 8), + hStack.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -8), + threadLinkView.widthAnchor.constraint(equalToConstant: 5), - threadLinkView.centerXAnchor.constraint(equalTo: contentView.layoutMarginsGuide.leadingAnchor, constant: 25), threadLinkView.topAnchor.constraint(equalTo: contentView.topAnchor), - threadLinkViewFullHeightConstraint, + threadLinkView.centerXAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16 + (50 / 2)) ]) NotificationCenter.default.addObserver(self, selector: #selector(preferencesChanged), name: .preferencesChanged, object: nil) } + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + func updateUI(childThreads: [ConversationNode], inline: Bool) { - stackViewLeadingConstraint.constant = inline ? 50 + 4 : 0 + stackViewLeadingConstraint.constant = 16 + (inline ? 50 + 4 : 0) threadLinkView.layer.maskedCorners = inline ? [] : [.layerMinXMaxYCorner, .layerMaxXMaxYCorner] threadLinkViewFullHeightConstraint.isActive = inline threadLinkViewShortHeightConstraint.isActive = !inline let format: String if inline { - format = NSLocalizedString("expand threads inline count", comment: "expnad converstaion threads inline button label") + format = NSLocalizedString("expand threads inline count", comment: "expand conversation threads inline button label") } else { format = NSLocalizedString("expand threads count", comment: "expand conversation threads button label") } replyCountLabel.text = String.localizedStringWithFormat(format, childThreads.count) let accounts = childThreads.map(\.status.account).uniques().prefix(3) - avatarImageViews.forEach { $0.removeFromSuperview() } avatarImageViews = [] - avatarRequests = [] - let avatarImageSize: CGFloat = 44 - 12 - if accounts.count == 1 { avatarContainerWidthConstraint.constant = avatarImageSize } else { @@ -71,7 +103,7 @@ class ExpandThreadTableViewCell: UITableViewCell { } for (index, account) in accounts.enumerated() { - let accountImageView = UIImageView() + let accountImageView = CachedImageView(cache: .avatars) accountImageView.translatesAutoresizingMaskIntoConstraints = false accountImageView.contentMode = .scaleAspectFit accountImageView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadiusFraction * avatarImageSize @@ -81,9 +113,8 @@ class ExpandThreadTableViewCell: UITableViewCell { // need a solid background color so semi-transparent avatars don't look bad accountImageView.backgroundColor = .secondarySystemBackground avatarContainerView.addSubview(accountImageView) - avatarImageViews.append(accountImageView) - + accountImageView.layer.zPosition = CGFloat(-index) let xConstraint: NSLayoutConstraint @@ -98,32 +129,17 @@ class ExpandThreadTableViewCell: UITableViewCell { accountImageView.widthAnchor.constraint(equalToConstant: avatarImageSize), accountImageView.heightAnchor.constraint(equalToConstant: avatarImageSize), accountImageView.centerYAnchor.constraint(equalTo: avatarContainerView.centerYAnchor), - xConstraint + xConstraint, ]) - if let avatar = account.avatar { - let req = ImageCache.avatars.get(avatar) { [weak accountImageView] (_, image) in - DispatchQueue.main.async { - accountImageView?.image = image - } - } - if let req = req { - avatarRequests.append(req) - } - } + accountImageView.update(for: account.avatar) } } @objc private func preferencesChanged() { - avatarImageViews.forEach { - $0.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadius(for: $0) + for view in avatarImageViews { + view.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadius(for: view) } } - override func prepareForReuse() { - super.prepareForReuse() - - avatarRequests.forEach { $0.cancel() } - } - } diff --git a/Tusker/Screens/Conversation/ExpandThreadTableViewCell.xib b/Tusker/Screens/Conversation/ExpandThreadTableViewCell.xib deleted file mode 100644 index 744b89d1..00000000 --- a/Tusker/Screens/Conversation/ExpandThreadTableViewCell.xib +++ /dev/null @@ -1,81 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/Tusker/Views/Status/ConversationMainStatusCollectionViewCell.swift b/Tusker/Views/Status/ConversationMainStatusCollectionViewCell.swift new file mode 100644 index 00000000..b7de6c8c --- /dev/null +++ b/Tusker/Views/Status/ConversationMainStatusCollectionViewCell.swift @@ -0,0 +1,505 @@ +// +// ConversationMainStatusCollectionViewCell.swift +// Tusker +// +// Created by Shadowfacts on 1/20/23. +// Copyright © 2023 Shadowfacts. All rights reserved. +// + +import UIKit +import Pachyderm +import Combine + +class ConversationMainStatusCollectionViewCell: UICollectionViewListCell, StatusCollectionViewCell { + + static let contentFont = UIFontMetrics.default.scaledFont(for: .systemFont(ofSize: 18)) + static let monospaceFont = UIFontMetrics.default.scaledFont(for: .monospacedSystemFont(ofSize: 18, weight: .regular)) + static let contentParagraphStyle = HTMLConverter.defaultParagraphStyle + static let metaFont = UIFontMetrics(forTextStyle: .caption1).scaledFont(for: .systemFont(ofSize: 15)) + + static let dateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateStyle = .medium + formatter.timeStyle = .medium + return formatter + }() + + // MARK: Subviews + + private static let avatarImageViewSize: CGFloat = 50 + private(set) lazy var avatarImageView = CachedImageView(cache: .avatars).configure { + $0.layer.masksToBounds = true + NSLayoutConstraint.activate([ + $0.heightAnchor.constraint(equalToConstant: ConversationMainStatusCollectionViewCell.avatarImageViewSize), + $0.widthAnchor.constraint(equalToConstant: ConversationMainStatusCollectionViewCell.avatarImageViewSize), + ]) + $0.isUserInteractionEnabled = true + $0.addInteraction(UIPointerInteraction(delegate: self)) + $0.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(accountPressed))) + } + + let displayNameLabel = EmojiLabel().configure { + $0.font = UIFontMetrics(forTextStyle: .title1).scaledFont(for: .systemFont(ofSize: 24, weight: .semibold)) + $0.adjustsFontForContentSizeCategory = true + } + + private let metaIndicatorsView = StatusMetaIndicatorsView().configure { + $0.allowedIndicators = [.visibility, .localOnly] + $0.squeezeHorizontal = true + $0.primaryAxis = .horizontal + } + + let usernameLabel = UILabel().configure { + $0.textColor = .secondaryLabel + $0.font = UIFontMetrics(forTextStyle: .title2).scaledFont(for: .systemFont(ofSize: 17, weight: .light)) + $0.adjustsFontForContentSizeCategory = true + } + + private lazy var accountDetailContainerView = UIView().configure { + $0.backgroundColor = .clear + $0.addSubview(avatarImageView) + $0.addSubview(displayNameLabel) + $0.addSubview(metaIndicatorsView) + $0.addSubview(usernameLabel) + avatarImageView.translatesAutoresizingMaskIntoConstraints = false + displayNameLabel.translatesAutoresizingMaskIntoConstraints = false + metaIndicatorsView.translatesAutoresizingMaskIntoConstraints = false + usernameLabel.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + avatarImageView.leadingAnchor.constraint(equalTo: $0.leadingAnchor), + avatarImageView.topAnchor.constraint(equalTo: $0.topAnchor), + avatarImageView.bottomAnchor.constraint(equalTo: $0.bottomAnchor), + + displayNameLabel.leadingAnchor.constraint(equalTo: avatarImageView.trailingAnchor, constant: 8), + displayNameLabel.trailingAnchor.constraint(equalTo: metaIndicatorsView.leadingAnchor, constant: -8), + displayNameLabel.topAnchor.constraint(equalTo: $0.topAnchor), + + metaIndicatorsView.topAnchor.constraint(equalTo: $0.topAnchor), + metaIndicatorsView.trailingAnchor.constraint(equalTo: $0.trailingAnchor), + + usernameLabel.leadingAnchor.constraint(equalTo: displayNameLabel.leadingAnchor), + usernameLabel.trailingAnchor.constraint(equalTo: $0.trailingAnchor), + usernameLabel.topAnchor.constraint(equalTo: displayNameLabel.bottomAnchor), + usernameLabel.bottomAnchor.constraint(equalTo: $0.bottomAnchor), + ]) + $0.isUserInteractionEnabled = true + $0.addInteraction(UIContextMenuInteraction(delegate: self)) + $0.addInteraction(UIDragInteraction(delegate: self)) + $0.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(accountPressed))) + } + private lazy var accountDetailAccessibilityElement = ConversationMainStatusAccountDetailAccessibilityElement(accessibilityContainer: self) + + private(set) lazy var contentWarningLabel = EmojiLabel().configure { + $0.numberOfLines = 0 + $0.textColor = .secondaryLabel + $0.font = UIFont(descriptor: .preferredFontDescriptor(withTextStyle: .body).addingAttributes([ + .traits: [ + UIFontDescriptor.TraitKey.weight: UIFont.Weight.bold.rawValue, + ] + ]), size: 0) + $0.adjustsFontForContentSizeCategory = true + // this needs to have a higher priorty than the content container's zero height constraint + $0.setContentHuggingPriority(.defaultHigh, for: .vertical) + $0.isUserInteractionEnabled = true + $0.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(collapseButtonPressed))) + } + + private(set) lazy var collapseButton = StatusCollapseButton(configuration: { + var config = UIButton.Configuration.filled() + config.image = UIImage(systemName: "chevron.down") + return config + }()).configure { + // this button is so big that dimming its background color is visually distracting + $0.tintAdjustmentMode = .normal + $0.setContentHuggingPriority(.defaultHigh, for: .vertical) + $0.addTarget(self, action: #selector(collapseButtonPressed), for: .touchUpInside) + } + + let contentContainer = StatusContentContainer(useTopSpacer: true).configure { + $0.contentTextView.defaultFont = ConversationMainStatusCollectionViewCell.contentFont + $0.contentTextView.monospaceFont = ConversationMainStatusCollectionViewCell.monospaceFont + $0.contentTextView.paragraphStyle = ConversationMainStatusCollectionViewCell.contentParagraphStyle + $0.contentTextView.isSelectable = true + $0.contentTextView.dataDetectorTypes = [.flightNumber, .address, .shipmentTrackingNumber, .phoneNumber] + if #available(iOS 16.0, *) { + $0.contentTextView.dataDetectorTypes.formUnion([.money, .physicalValue]) + } + $0.setContentHuggingPriority(.defaultLow, for: .vertical) + } + + private lazy var favoritesCountButton = UIButton().configure { + $0.titleLabel!.adjustsFontForContentSizeCategory = true + $0.addTarget(self, action: #selector(favoritesCountPressed), for: .touchUpInside) + $0.isPointerInteractionEnabled = true + } + + private lazy var reblogsCountButton = UIButton().configure { + $0.titleLabel!.adjustsFontForContentSizeCategory = true + $0.addTarget(self, action: #selector(reblogsCountPressed), for: .touchUpInside) + $0.isPointerInteractionEnabled = true + } + + private lazy var actionsCountHStack = UIStackView(arrangedSubviews: [ + favoritesCountButton, + reblogsCountButton, + ]).configure { + $0.axis = .horizontal + $0.spacing = 8 + } + + private let timestampAndClientLabel = UILabel().configure { + $0.textColor = .secondaryLabel + $0.font = ConversationMainStatusCollectionViewCell.metaFont + $0.adjustsFontForContentSizeCategory = true + } + + private let firstSeparator = UIView().configure { + $0.backgroundColor = .separator + NSLayoutConstraint.activate([ + $0.heightAnchor.constraint(equalToConstant: 0.5), + ]) + } + private let secondSeparator = UIView().configure { + $0.backgroundColor = .separator + NSLayoutConstraint.activate([ + $0.heightAnchor.constraint(equalToConstant: 0.5), + ]) + } + + private lazy var metaVStack = UIStackView(arrangedSubviews: [ + firstSeparator, + actionsCountHStack, + timestampAndClientLabel, + secondSeparator, + ]).configure { + $0.axis = .vertical + $0.spacing = 4 + $0.alignment = .leading + } + + private(set) lazy var replyButton = UIButton().configure { + $0.setImage(UIImage(systemName: "arrowshape.turn.up.left.fill"), for: .normal) + $0.addTarget(self, action: #selector(replyPressed), for: .touchUpInside) + $0.addInteraction(UIPointerInteraction(delegate: self)) + } + + private(set) lazy var favoriteButton = UIButton().configure { + $0.setImage(UIImage(systemName: "star.fill"), for: .normal) + $0.addTarget(self, action: #selector(favoritePressed), for: .touchUpInside) + $0.addInteraction(UIPointerInteraction(delegate: self)) + } + + private(set) lazy var reblogButton = UIButton().configure { + $0.setImage(UIImage(systemName: "repeat"), for: .normal) + $0.addTarget(self, action: #selector(reblogPressed), for: .touchUpInside) + $0.addInteraction(UIPointerInteraction(delegate: self)) + } + + private(set) lazy var moreButton = UIButton().configure { + $0.setImage(UIImage(systemName: "ellipsis"), for: .normal) + $0.showsMenuAsPrimaryAction = true + $0.addInteraction(UIPointerInteraction(delegate: self)) + } + + private var actionButtons: [UIButton] { + [replyButton, favoriteButton, reblogButton, moreButton] + } + + private lazy var actionsHStack = UIStackView(arrangedSubviews: [ + replyButton, + favoriteButton, + reblogButton, + moreButton, + ]).configure { + $0.axis = .horizontal + $0.distribution = .fillEqually + NSLayoutConstraint.activate([ + $0.heightAnchor.constraint(equalToConstant: 26), + ]) + } + + private let accountDetailToContentSpacer = UIView().configure { + $0.backgroundColor = .clear + $0.heightAnchor.constraint(equalToConstant: 8).isActive = true + } + private let contentWarningToCollapseButtonSpacer = UIView().configure { + $0.backgroundColor = .clear + $0.heightAnchor.constraint(equalToConstant: 4).isActive = true + } + private let contentToMetaSpacer = UIView().configure { + $0.backgroundColor = .clear + $0.heightAnchor.constraint(equalToConstant: 8).isActive = true + } + private let metaToActionsSpacer = UIView().configure { + $0.backgroundColor = .clear + $0.heightAnchor.constraint(equalToConstant: 8).isActive = true + } + + private lazy var mainVStack = UIStackView(arrangedSubviews: [ + accountDetailContainerView, + accountDetailToContentSpacer, + contentWarningLabel, + contentWarningToCollapseButtonSpacer, + collapseButton, + contentContainer, + contentToMetaSpacer, + metaVStack, + metaToActionsSpacer, + actionsHStack, + ]).configure { + $0.axis = .vertical + $0.spacing = 0 + $0.alignment = .leading + + NSLayoutConstraint.activate([ + accountDetailContainerView.widthAnchor.constraint(equalTo: $0.widthAnchor), + contentWarningLabel.widthAnchor.constraint(equalTo: $0.widthAnchor), + collapseButton.widthAnchor.constraint(equalTo: $0.widthAnchor), + contentContainer.widthAnchor.constraint(equalTo: $0.widthAnchor), + firstSeparator.widthAnchor.constraint(equalTo: $0.widthAnchor), + secondSeparator.widthAnchor.constraint(equalTo: $0.widthAnchor), + actionsHStack.widthAnchor.constraint(equalTo: $0.widthAnchor), + ]) + } + + var prevThreadLinkView: UIView? + var nextThreadLinkView: UIView? + + // MARK: Cell State + var mastodonController: MastodonController! { delegate?.apiController } + weak var delegate: StatusCollectionViewCellDelegate? + var showStatusAutomatically = false + + var statusID: String! + var statusState: CollapseState! + var accountID: String! + + var isGrayscale = false + private var hasCreatedObservers = false + var cancellables = Set() + + override init(frame: CGRect) { + super.init(frame: frame) + + // don't show selection background, because this cell isn't selectable + automaticallyUpdatesBackgroundConfiguration = false + + mainVStack.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(mainVStack) + + NSLayoutConstraint.activate([ + mainVStack.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16), + mainVStack.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -16), + mainVStack.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 8), + mainVStack.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -8), + ]) + + accessibilityElements = [ + accountDetailAccessibilityElement, + contentWarningLabel, + collapseButton, + contentContainer.contentTextView, + contentContainer.attachmentsView, + contentContainer.pollView, + favoritesCountButton, + reblogsCountButton, + timestampAndClientLabel, + replyButton, + favoriteButton, + reblogButton, + moreButton, + ] + + NotificationCenter.default.addObserver(self, selector: #selector(preferencesChanged), name: .preferencesChanged, object: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: Configure UI + + func updateUI(statusID: String, state: CollapseState) { + guard let status = mastodonController.persistentContainer.status(for: statusID) else { + fatalError() + } + + createObservers() + + self.statusID = statusID + self.statusState = state + + doUpdateUI(status: status) + + accountDetailAccessibilityElement.navigationDelegate = delegate + accountDetailAccessibilityElement.accountID = accountID + + var timestampAndClientText = ConversationMainStatusCollectionViewCell.dateFormatter.string(from: status.createdAt) + if let application = status.applicationName { + timestampAndClientText += " • \(application)" + } + timestampAndClientLabel.text = timestampAndClientText + } + + private func createObservers() { + guard !hasCreatedObservers else { + return + } + hasCreatedObservers = true + baseCreateObservers() + } + + func updateStatusState(status: StatusMO) { + baseUpdateStatusState(status: status) + + let attributes = AttributeContainer([ + .font: ConversationMainStatusCollectionViewCell.metaFont + ]) + + let favoritesFormat = NSLocalizedString("favorites count", comment: "conv main status favorites button label") + var favoritesConfig = UIButton.Configuration.plain() + favoritesConfig.baseForegroundColor = .secondaryLabel + favoritesConfig.attributedTitle = AttributedString(String.localizedStringWithFormat(favoritesFormat, status.favouritesCount), attributes: attributes) + favoritesConfig.contentInsets = .zero + favoritesCountButton.configuration = favoritesConfig + + let reblogsFormat = NSLocalizedString("reblogs count", comment: "conv main status reblogs button label") + var reblogsConfig = UIButton.Configuration.plain() + reblogsConfig.baseForegroundColor = .secondaryLabel + reblogsConfig.attributedTitle = AttributedString(String.localizedStringWithFormat(reblogsFormat, status.reblogsCount), attributes: attributes) + reblogsConfig.contentInsets = .zero + reblogsCountButton.configuration = reblogsConfig + } + + func updateUIForPreferences(status: StatusMO) { + baseUpdateUIForPreferences(status: status) + } + + @objc private func preferencesChanged() { + guard let mastodonController, + let statusID, + let status = mastodonController.persistentContainer.status(for: statusID) else { + return + } + + updateUIForPreferences(status: status) + + if isGrayscale != Preferences.shared.grayscaleImages { + updateGrayscaleableUI(status: status) + } + + if actionsCountHStack.isHidden != !Preferences.shared.showFavoriteAndReblogCounts { + actionsCountHStack.isHidden = !Preferences.shared.showFavoriteAndReblogCounts + delegate?.statusCellNeedsReconfigure(self, animated: true, completion: nil) + } + } + + // MARK: Interaction + + @objc private func accountPressed() { + delegate?.selected(account: accountID) + } + + @objc private func collapseButtonPressed() { + toggleCollapse() + } + + @objc private func favoritesCountPressed() { + guard let delegate else { + return + } + let vc = delegate.statusActionAccountList(action: .favorite, statusID: statusID, statusState: statusState.copy(), accountIDs: nil) + // TODO: only show warning if the instance isn't the logged in one + vc.showInacurateCountWarning = true + delegate.show(vc) + } + + @objc private func reblogsCountPressed() { + guard let delegate else { + return + } + let vc = delegate.statusActionAccountList(action: .reblog, statusID: statusID, statusState: statusState.copy(), accountIDs: nil) + vc.showInacurateCountWarning = true + delegate.show(vc) + } + + @objc private func replyPressed() { + delegate?.compose(inReplyToID: statusID) + } + + @objc private func favoritePressed() { + toggleFavorite() + } + + @objc private func reblogPressed() { + toggleReblog() + } + +} + +private class ConversationMainStatusAccountDetailAccessibilityElement: UIAccessibilityElement { + var navigationDelegate: TuskerNavigationDelegate! + var mastodonController: MastodonController { navigationDelegate.apiController } + var accountID: String! + + override var accessibilityLabel: String? { + get { mastodonController.persistentContainer.account(for: accountID)?.displayNameWithoutCustomEmoji } + set {} + } + + override var accessibilityHint: String? { + get { "Double tap to show profile." } + set {} + } + + override func accessibilityActivate() -> Bool { + navigationDelegate.selected(account: accountID) + return true + } +} + +extension ConversationMainStatusCollectionViewCell: UIContextMenuInteractionDelegate { + func contextMenuInteraction(_ interaction: UIContextMenuInteraction, configurationForMenuAtLocation location: CGPoint) -> UIContextMenuConfiguration? { + return contextMenuConfigurationForAccount(sourceView: accountDetailContainerView) + } + + func contextMenuInteraction(_ interaction: UIContextMenuInteraction, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) { + if let delegate { + MenuPreviewHelper.willPerformPreviewAction(animator: animator, presenter: delegate) + } + } +} + +extension ConversationMainStatusCollectionViewCell: UIDragInteractionDelegate { + func dragInteraction(_ interaction: UIDragInteraction, itemsForBeginning session: UIDragSession) -> [UIDragItem] { + return dragItemsForAccount() + } +} + +extension ConversationMainStatusCollectionViewCell: UIPointerInteractionDelegate { + func pointerInteraction(_ interaction: UIPointerInteraction, regionFor request: UIPointerRegionRequest, defaultRegion: UIPointerRegion) -> UIPointerRegion? { + if interaction.view == avatarImageView { + return defaultRegion + } else if let button = interaction.view as? UIButton, + actionButtons.contains(button) { + var rect = button.convert(button.imageView!.bounds, to: button.imageView!) + rect = rect.insetBy(dx: -24, dy: -24) + return UIPointerRegion(rect: rect) + } + return nil + } + + func pointerInteraction(_ interaction: UIPointerInteraction, styleFor region: UIPointerRegion) -> UIPointerStyle? { + if interaction.view == avatarImageView { + let preview = UITargetedPreview(view: avatarImageView) + return UIPointerStyle(effect: .lift(preview)) + } else if let button = interaction.view as? UIButton, + actionButtons.contains(button) { + let preview = UITargetedPreview(view: button.imageView!) + var rect = button.convert(button.imageView!.bounds, to: button.imageView!) + rect = rect.insetBy(dx: -24, dy: -24) + return UIPointerStyle(effect: .highlight(preview), shape: .roundedRect(rect)) + } + return nil + } +} diff --git a/Tusker/Views/Status/ConversationMainStatusTableViewCell.swift b/Tusker/Views/Status/ConversationMainStatusTableViewCell.swift deleted file mode 100644 index 8f812ea7..00000000 --- a/Tusker/Views/Status/ConversationMainStatusTableViewCell.swift +++ /dev/null @@ -1,163 +0,0 @@ -// -// ConversationMainStatusTableViewCell.swift -// Tusker -// -// Created by Shadowfacts on 8/28/18. -// Copyright © 2018 Shadowfacts. All rights reserved. -// - -import UIKit -import Combine -import Pachyderm - -class ConversationMainStatusTableViewCell: BaseStatusTableViewCell { - - static let dateFormatter: DateFormatter = { - let formatter = DateFormatter() - formatter.dateStyle = .medium - formatter.timeStyle = .medium - return formatter - }() - - - @IBOutlet weak var profileDetailContainerView: UIView! - @IBOutlet weak var favoriteAndReblogCountStackView: UIStackView! - @IBOutlet weak var totalFavoritesButton: UIButton! - @IBOutlet weak var totalReblogsButton: UIButton! - @IBOutlet weak var timestampAndClientLabel: UILabel! - - private var profileAccessibilityElement: ConversationMainStatusProfileAccessibilityElement! - - override func awakeFromNib() { - super.awakeFromNib() - - profileAccessibilityElement = ConversationMainStatusProfileAccessibilityElement(accessibilityContainer: self) - profileAccessibilityElement.accessibilityFrameInContainerSpace = profileDetailContainerView.convert(profileDetailContainerView.frame, to: self) - accessibilityElements = [ - profileAccessibilityElement!, - contentWarningLabel!, - collapseButton!, - contentTextView!, - attachmentsView!, - pollView!, - totalFavoritesButton!, - totalReblogsButton!, - timestampAndClientLabel!, - replyButton!, - favoriteButton!, - reblogButton!, - moreButton!, - ] - - profileDetailContainerView.addInteraction(UIContextMenuInteraction(delegate: self)) - - displayNameLabel.font = UIFontMetrics(forTextStyle: .title1).scaledFont(for: .systemFont(ofSize: 24, weight: .semibold)) - displayNameLabel.adjustsFontForContentSizeCategory = true - - usernameLabel.font = UIFontMetrics(forTextStyle: .title2).scaledFont(for: .systemFont(ofSize: 17, weight: .light)) - usernameLabel.adjustsFontForContentSizeCategory = true - - metaIndicatorsView.allowedIndicators = [.visibility, .localOnly] - metaIndicatorsView.squeezeHorizontal = true - metaIndicatorsView.primaryAxis = .horizontal - - contentWarningLabel.font = .preferredFont(forTextStyle: .body).withTraits(.traitBold)! - contentWarningLabel.adjustsFontForContentSizeCategory = true - - contentTextView.defaultFont = UIFontMetrics.default.scaledFont(for: .systemFont(ofSize: 18)) - contentTextView.monospaceFont = UIFontMetrics.default.scaledFont(for: .monospacedSystemFont(ofSize: 18, weight: .regular)) - contentTextView.adjustsFontForContentSizeCategory = true - contentTextView.dataDetectorTypes = [.flightNumber, .address, .shipmentTrackingNumber, .phoneNumber] - if #available(iOS 16.0, *) { - contentTextView.dataDetectorTypes.formUnion([.money, .physicalValue]) - } - - let metaFont = UIFontMetrics(forTextStyle: .caption1).scaledFont(for: .systemFont(ofSize: 15)) - totalFavoritesButton.titleLabel!.font = metaFont - totalFavoritesButton.titleLabel!.adjustsFontForContentSizeCategory = true - totalReblogsButton.titleLabel!.font = metaFont - totalReblogsButton.titleLabel!.adjustsFontForContentSizeCategory = true - timestampAndClientLabel.font = metaFont - timestampAndClientLabel.adjustsFontForContentSizeCategory = true - } - - override func doUpdateUI(status: StatusMO, state: CollapseState) { - super.doUpdateUI(status: status, state: state) - - var timestampAndClientText = ConversationMainStatusTableViewCell.dateFormatter.string(from: status.createdAt) - if let application = status.applicationName { - timestampAndClientText += " • \(application)" - } - timestampAndClientLabel.text = timestampAndClientText - } - - override func updateStatusState(status: StatusMO) { - super.updateStatusState(status: status) - - let favoritesFormat = NSLocalizedString("favorites count", comment: "conv main status favorites button label") - totalFavoritesButton.setTitle(String.localizedStringWithFormat(favoritesFormat, status.favouritesCount), for: .normal) - let reblogsFormat = NSLocalizedString("reblogs count", comment: "conv main status reblogs button label") - totalReblogsButton.setTitle(String.localizedStringWithFormat(reblogsFormat, status.reblogsCount), for: .normal) - } - - override func updateUI(account: AccountMO) { - super.updateUI(account: account) - profileAccessibilityElement.navigationDelegate = delegate - profileAccessibilityElement.accountID = account.id - } - - override func updateUIForPreferences(account: AccountMO, status: StatusMO) { - super.updateUIForPreferences(account: account, status: status) - - favoriteAndReblogCountStackView.isHidden = !Preferences.shared.showFavoriteAndReblogCounts - } - - @IBAction func totalFavoritesPressed() { - if let delegate = delegate { - // accounts aren't known, pass nil so the VC will load them - let vc = delegate.statusActionAccountList(action: .favorite, statusID: statusID, statusState: statusState.copy(), accountIDs: nil) - vc.showInacurateCountWarning = true - delegate.show(vc) - } - } - - @IBAction func totalReblogsPressed() { - if let delegate = delegate { - // accounts aren't known, pass nil so the VC will load them - let vc = delegate.statusActionAccountList(action: .reblog, statusID: statusID, statusState: statusState.copy(), accountIDs: nil) - vc.showInacurateCountWarning = true - delegate.show(vc) - } - } -} - -private class ConversationMainStatusProfileAccessibilityElement: UIAccessibilityElement { - var navigationDelegate: TuskerNavigationDelegate! - var mastodonController: MastodonController { navigationDelegate.apiController } - var accountID: String! - - override var accessibilityLabel: String? { - get { mastodonController.persistentContainer.account(for: accountID)?.displayNameWithoutCustomEmoji } - set {} - } - - override var accessibilityHint: String? { - get { "Double tap to show profile." } - set {} - } - - override func accessibilityActivate() -> Bool { - navigationDelegate.selected(account: accountID) - return true - } -} - -extension ConversationMainStatusTableViewCell: UIContextMenuInteractionDelegate { - func contextMenuInteraction(_ interaction: UIContextMenuInteraction, configurationForMenuAtLocation location: CGPoint) -> UIContextMenuConfiguration? { - return UIContextMenuConfiguration(identifier: nil) { - ProfileViewController(accountID: self.accountID, mastodonController: self.mastodonController) - } actionProvider: { (_) in - return UIMenu(title: "", image: nil, identifier: nil, options: [], children: self.delegate?.actionsForProfile(accountID: self.accountID, source: .view(self.avatarImageView)) ?? []) - } - } -} diff --git a/Tusker/Views/Status/ConversationMainStatusTableViewCell.xib b/Tusker/Views/Status/ConversationMainStatusTableViewCell.xib deleted file mode 100644 index b89047f4..00000000 --- a/Tusker/Views/Status/ConversationMainStatusTableViewCell.xib +++ /dev/null @@ -1,293 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - Lorem ipsum dolor sit er elit lamet, consectetaur cillium adipisicing pecu, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. Nam liber te conscient to factor tum poen legum odioque civiuda. - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/Tusker/Views/Status/StatusCollectionViewCell.swift b/Tusker/Views/Status/StatusCollectionViewCell.swift index 52ab6e58..9a27d04a 100644 --- a/Tusker/Views/Status/StatusCollectionViewCell.swift +++ b/Tusker/Views/Status/StatusCollectionViewCell.swift @@ -29,12 +29,13 @@ protocol StatusCollectionViewCell: UICollectionViewCell, AttachmentViewDelegate var favoriteButton: UIButton { get } var reblogButton: UIButton { get } var moreButton: UIButton { get } + var prevThreadLinkView: UIView? { get set } + var nextThreadLinkView: UIView? { get set } var delegate: StatusCollectionViewCellDelegate? { get } var mastodonController: MastodonController! { get } var showStatusAutomatically: Bool { get } - var showReplyIndicator: Bool { get } var statusID: String! { get set } var statusState: CollapseState! { get set } @@ -44,6 +45,7 @@ protocol StatusCollectionViewCell: UICollectionViewCell, AttachmentViewDelegate var cancellables: Set { get set } func updateUIForPreferences(status: StatusMO) + func updateStatusState(status: StatusMO) } // MARK: UI Configuration @@ -179,7 +181,7 @@ extension StatusCollectionViewCell { displayNameLabel.updateForAccountDisplayName(account: status.account) } - func updateStatusState(status: StatusMO) { + func baseUpdateStatusState(status: StatusMO) { if status.favourited { favoriteButton.tintColor = UIColor(displayP3Red: 1, green: 0.8, blue: 0, alpha: 1) favoriteButton.accessibilityLabel = NSLocalizedString("Undo Favorite", comment: "undo favorite button accessibility label") @@ -204,6 +206,55 @@ extension StatusCollectionViewCell { contentContainer.pollView.toastableViewController = delegate?.toastableViewController contentContainer.pollView.updateUI(status: status, poll: status.poll) } + + func setShowThreadLinks(prev: Bool, next: Bool) { + if prev { + if let prevThreadLinkView { + prevThreadLinkView.isHidden = false + } else { + let view = UIView() + view.translatesAutoresizingMaskIntoConstraints = false + view.backgroundColor = .tintColor.withAlphaComponent(0.5) + view.layer.cornerRadius = 2.5 + view.layer.maskedCorners = [.layerMinXMaxYCorner, .layerMaxXMaxYCorner] + prevThreadLinkView = view + contentView.addSubview(view) + NSLayoutConstraint.activate([ + view.widthAnchor.constraint(equalToConstant: 5), + view.centerXAnchor.constraint(equalTo: avatarImageView.centerXAnchor), + view.topAnchor.constraint(equalTo: contentView.topAnchor), + view.bottomAnchor.constraint(equalTo: avatarImageView.topAnchor, constant: -2), + ]) + } + } else { + prevThreadLinkView?.isHidden = true + } + + if next { + if let nextThreadLinkView { + nextThreadLinkView.isHidden = false + } else { + let view = UIView() + view.translatesAutoresizingMaskIntoConstraints = false + view.backgroundColor = .tintColor.withAlphaComponent(0.5) + view.layer.cornerRadius = 2.5 + view.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner] + nextThreadLinkView = view + contentView.addSubview(view) + let bottomConstraint = view.bottomAnchor.constraint(equalTo: contentView.bottomAnchor) + // let this constraint get broken during intermediate layouts to avoid a bunch of spurious 'unable to simultaneously satisfy constraints' messages + bottomConstraint.priority = .init(999) + NSLayoutConstraint.activate([ + view.widthAnchor.constraint(equalToConstant: 5), + view.centerXAnchor.constraint(equalTo: avatarImageView.centerXAnchor), + view.topAnchor.constraint(equalTo: avatarImageView.bottomAnchor, constant: 2), + bottomConstraint, + ]) + } + } else { + nextThreadLinkView?.isHidden = true + } + } } // MARK: Interaction diff --git a/Tusker/Views/Status/StatusContentContainer.swift b/Tusker/Views/Status/StatusContentContainer.swift index 2ea5dab7..2cbb7215 100644 --- a/Tusker/Views/Status/StatusContentContainer.swift +++ b/Tusker/Views/Status/StatusContentContainer.swift @@ -9,8 +9,15 @@ import UIKit class StatusContentContainer: UIView { + + private var useTopSpacer = false + private let topSpacer = UIView().configure { + $0.backgroundColor = .clear + // other 4pt is provided by this view's own spacing + $0.heightAnchor.constraint(equalToConstant: 4).isActive = true + } - let contentTextView = StatusContentTextView().configure { + let contentTextView = StatusContentTextView().configure { $0.adjustsFontForContentSizeCategory = true $0.isScrollEnabled = false $0.backgroundColor = nil @@ -29,7 +36,11 @@ class StatusContentContainer: UIView { let pollView = StatusPollView() private var arrangedSubviews: [UIView] { - [contentTextView, cardView, attachmentsView, pollView] + if useTopSpacer { + return [topSpacer, contentTextView, cardView, attachmentsView, pollView] + } else { + return [contentTextView, cardView, attachmentsView, pollView] + } } private var isHiddenObservations: [NSKeyValueObservation] = [] @@ -44,8 +55,10 @@ class StatusContentContainer: UIView { subviews.filter { !$0.isHidden }.map(\.bounds.height).reduce(0, +) } - override init(frame: CGRect) { - super.init(frame: frame) + init(useTopSpacer: Bool) { + self.useTopSpacer = useTopSpacer + + super.init(frame: .zero) for subview in arrangedSubviews { subview.translatesAutoresizingMaskIntoConstraints = false diff --git a/Tusker/Views/Status/TimelineStatusCollectionViewCell.swift b/Tusker/Views/Status/TimelineStatusCollectionViewCell.swift index 8fc090fb..b02b475d 100644 --- a/Tusker/Views/Status/TimelineStatusCollectionViewCell.swift +++ b/Tusker/Views/Status/TimelineStatusCollectionViewCell.swift @@ -166,7 +166,7 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell, StatusCollecti $0.addTarget(self, action: #selector(collapseButtonPressed), for: .touchUpInside) } - let contentContainer = StatusContentContainer().configure { + let contentContainer = StatusContentContainer(useTopSpacer: false).configure { $0.contentTextView.defaultFont = TimelineStatusCollectionViewCell.contentFont $0.contentTextView.monospaceFont = TimelineStatusCollectionViewCell.monospaceFont $0.contentTextView.paragraphStyle = TimelineStatusCollectionViewCell.contentParagraphStyle @@ -260,6 +260,9 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell, StatusCollecti $0.translatesAutoresizingMaskIntoConstraints = false } + var prevThreadLinkView: UIView? + var nextThreadLinkView: UIView? + // MARK: Cell state private var mainContainerTopToReblogLabelConstraint: NSLayoutConstraint! @@ -270,14 +273,8 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell, StatusCollecti weak var overrideMastodonController: MastodonController? var mastodonController: MastodonController! { overrideMastodonController ?? delegate?.apiController } weak var delegate: StatusCollectionViewCellDelegate? - var showStatusAutomatically: Bool { - // TODO: needed once conversation controller refactored - false - } - var showReplyIndicator: Bool { - // TODO: needed once conversation controller refactored - true - } + var showStatusAutomatically = false + var showReplyIndicator = true var showPinned: Bool = false var showFollowedHashtags: Bool = false @@ -574,7 +571,7 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell, StatusCollecti pinImageView.isHidden = !showPinned } - func createObservers() { + private func createObservers() { guard !hasCreatedObservers else { return } @@ -609,6 +606,10 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell, StatusCollecti } } + func updateStatusState(status: StatusMO) { + baseUpdateStatusState(status: status) + } + private func updateTimestamp() { guard let mastodonController, let status = mastodonController.persistentContainer.status(for: statusID) else {