Compare commits

...

5 Commits

14 changed files with 1268 additions and 1017 deletions

View File

@ -1,5 +1,11 @@
# Changelog
## 2023.2 (67)
Features/Improvements:
- Improve collapse/expand animation on conversation screen
- Add loading indicator to conversation screen
- Try to resolve remote statuses and show conversation screen when tapping status links
## 2023.2 (66)
Features/Improvements:
- Improve design of link preview card

View File

@ -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 = "<group>"; };
04ED00B021481ED800567C53 /* SteppedProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SteppedProgressView.swift; sourceTree = "<group>"; };
D601FA5A29787AB100A8E8B5 /* AccountFollowsListViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountFollowsListViewController.swift; sourceTree = "<group>"; };
D601FA5C297B2E6F00A8E8B5 /* ConversationCollectionViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationCollectionViewController.swift; sourceTree = "<group>"; };
D601FA5E297B339100A8E8B5 /* ExpandThreadCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExpandThreadCollectionViewCell.swift; sourceTree = "<group>"; };
D601FA60297B539E00A8E8B5 /* ConversationMainStatusCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationMainStatusCollectionViewCell.swift; sourceTree = "<group>"; };
D6093F9A25BDD4B9004811E6 /* HashtagSearchResultsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HashtagSearchResultsViewController.swift; sourceTree = "<group>"; };
D6093FB625BE0CF3004811E6 /* TrendHistoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrendHistoryView.swift; sourceTree = "<group>"; };
D60D2B8123844C71001B87A3 /* BaseStatusTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BaseStatusTableViewCell.swift; sourceTree = "<group>"; };
@ -561,8 +562,6 @@
D662AEED263A3B880082A153 /* PollFinishedTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollFinishedTableViewCell.swift; sourceTree = "<group>"; };
D662AEEE263A3B880082A153 /* PollFinishedTableViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = PollFinishedTableViewCell.xib; sourceTree = "<group>"; };
D662AEF1263A4BE10082A153 /* ComposePollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposePollView.swift; sourceTree = "<group>"; };
D663625C2135C74800C9CBA2 /* ConversationMainStatusTableViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = ConversationMainStatusTableViewCell.xib; sourceTree = "<group>"; };
D663625E2135C75500C9CBA2 /* ConversationMainStatusTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationMainStatusTableViewCell.swift; sourceTree = "<group>"; };
D663626121360B1900C9CBA2 /* Preferences.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Preferences.swift; sourceTree = "<group>"; };
D663626321360D2300C9CBA2 /* AvatarStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AvatarStyle.swift; sourceTree = "<group>"; };
D663626B21361C6700C9CBA2 /* Account+Preferences.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Account+Preferences.swift"; sourceTree = "<group>"; };
@ -570,7 +569,6 @@
D6674AE923341F7600E8DF94 /* AppShortcutItems.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppShortcutItems.swift; sourceTree = "<group>"; };
D667E5E02134937B0057A976 /* TimelineStatusTableViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = TimelineStatusTableViewCell.xib; sourceTree = "<group>"; };
D667E5F02134D5050057A976 /* UIViewController+Delegates.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIViewController+Delegates.swift"; sourceTree = "<group>"; };
D667E5F42135BCD50057A976 /* ConversationTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationTableViewController.swift; sourceTree = "<group>"; };
D667E5F72135C3040057A976 /* Mastodon+Equatable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Mastodon+Equatable.swift"; sourceTree = "<group>"; };
D66A77BA233838DC0058F1EC /* UIFont+Traits.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIFont+Traits.swift"; sourceTree = "<group>"; };
D66C900A28DAB7FD00217BF2 /* TimelineViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineViewController.swift; sourceTree = "<group>"; };
@ -691,8 +689,6 @@
D6C693FD2162FEEA007D6A6D /* UIViewController+Children.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIViewController+Children.swift"; sourceTree = "<group>"; };
D6C7D27C22B6EBF800071952 /* AttachmentsContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachmentsContainerView.swift; sourceTree = "<group>"; };
D6C82B4025C5BB7E0017F1E6 /* ExploreViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExploreViewController.swift; sourceTree = "<group>"; };
D6C82B5425C5F3F20017F1E6 /* ExpandThreadTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExpandThreadTableViewCell.swift; sourceTree = "<group>"; };
D6C82B5525C5F3F20017F1E6 /* ExpandThreadTableViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = ExpandThreadTableViewCell.xib; sourceTree = "<group>"; };
D6C94D862139E62700CB5196 /* LargeImageViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LargeImageViewController.swift; sourceTree = "<group>"; };
D6C94D882139E6EC00CB5196 /* AttachmentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachmentView.swift; sourceTree = "<group>"; };
D6C99FCA24FADC91005C74D3 /* MainComposeTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainComposeTextView.swift; sourceTree = "<group>"; };
@ -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 = "<group>";
@ -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 */,
@ -2350,7 +2342,7 @@
CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 66;
CURRENT_PROJECT_VERSION = 67;
INFOPLIST_FILE = Tusker/Info.plist;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
@ -2418,7 +2410,7 @@
CODE_SIGN_ENTITLEMENTS = OpenInTusker/OpenInTusker.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 66;
CURRENT_PROJECT_VERSION = 67;
INFOPLIST_FILE = OpenInTusker/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.1;
"IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.3;
@ -2569,7 +2561,7 @@
CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 66;
CURRENT_PROJECT_VERSION = 67;
INFOPLIST_FILE = Tusker/Info.plist;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
@ -2598,7 +2590,7 @@
CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 66;
CURRENT_PROJECT_VERSION = 67;
INFOPLIST_FILE = Tusker/Info.plist;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
@ -2708,7 +2700,7 @@
CODE_SIGN_ENTITLEMENTS = OpenInTusker/OpenInTusker.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 66;
CURRENT_PROJECT_VERSION = 67;
INFOPLIST_FILE = OpenInTusker/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.1;
"IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.3;
@ -2734,7 +2726,7 @@
CODE_SIGN_ENTITLEMENTS = OpenInTusker/OpenInTusker.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 66;
CURRENT_PROJECT_VERSION = 67;
INFOPLIST_FILE = OpenInTusker/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.1;
"IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.3;

View File

@ -0,0 +1,433 @@
//
// 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<Section, Item>!
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<Section, Item> {
let statusCell = UICollectionView.CellRegistration<TimelineStatusCollectionViewCell, (String, CollapseState, Bool, Bool)> { [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<ConversationMainStatusCollectionViewCell, (String, CollapseState, Bool)> { [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<ExpandThreadCollectionViewCell, ([ConversationNode], Bool)> { cell, indexPath, item in
cell.updateUI(childThreads: item.0, inline: item.1)
}
let loadingCell = UICollectionView.CellRegistration<LoadingCollectionViewCell, Void> { cell, indexPath, itemIdentifier in
cell.indicator.startAnimating()
}
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))
case .loadingIndicator:
return collectionView.dequeueConfiguredReusableCell(using: loadingCell, for: indexPath, item: ())
}
}
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
clearSelectionOnAppear(animated: animated)
}
func addMainStatus(_ status: StatusMO) {
loadViewIfNeeded()
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
snapshot.appendSections([.statuses])
if status.inReplyToID != nil {
snapshot.appendItems([.loadingIndicator], toSection: .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()
snapshot.deleteItems([.loadingIndicator])
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<Section, Item>) {
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..<pivotIndex].sort(by: { $0.status.createdAt < $1.status.createdAt })
childThreads[pivotIndex...].sort(by: { $0.status.createdAt < $1.status.createdAt })
for node in childThreads {
let section = Section.childThread(firstStatusID: node.status.id)
snapshot.appendSections([section])
snapshot.appendItems([.status(id: node.status.id, state: .unknown, prevLink: false, nextLink: !node.children.isEmpty)], toSection: section)
var currentNode = node
while true {
let next: ConversationNode
if currentNode.children.count == 0 {
break
} else if currentNode.children.count == 1 {
next = currentNode.children[0]
} else {
let sameAuthorStatuses = currentNode.children.filter({ $0.status.account.id == node.status.account.id })
if sameAuthorStatuses.count == 1 {
next = sameAuthorStatuses[0]
let nonSameAuthorChildren = currentNode.children.filter { $0.status.id != sameAuthorStatuses[0].status.id }
snapshot.appendItems([.expandThread(childThreads: nonSameAuthorChildren, inline: true)])
} else {
snapshot.appendItems([.expandThread(childThreads: currentNode.children, inline: false)])
break
}
}
currentNode = next
snapshot.appendItems([.status(id: next.status.id, state: .unknown, prevLink: true, nextLink: !next.children.isEmpty)], toSection: section)
}
}
}
func updateVisibleCellCollapseState() {
var snapshot = dataSource.snapshot()
var cellsToMask: [StatusCollectionViewCell] = []
for item in snapshot.itemIdentifiers {
guard case .status(id: _, state: let state, prevLink: _, nextLink: _) = item,
state.collapsible == true else {
continue
}
state.collapsed = !showStatusesAutomatically
snapshot.reconfigureItems([item])
if let indexPath = dataSource.indexPath(for: item),
let cell = collectionView.cellForItem(at: indexPath) as? StatusCollectionViewCell {
cellsToMask.append(cell)
}
}
for cell in cellsToMask {
cell.contentContainer.layer.masksToBounds = true
}
dataSource.apply(snapshot, animatingDifferences: true) {
// this is an absurdly long delay, I have no idea why it's necessary
// without it, the layer is not maksed during the animation
// unless there's only one cell
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(500)) {
for cell in cellsToMask {
cell.contentContainer.layer.masksToBounds = false
}
}
}
}
}
extension ConversationCollectionViewController {
enum Section: Hashable {
case statuses
case childThread(firstStatusID: String)
}
enum Item: Hashable {
case status(id: String, state: CollapseState, prevLink: Bool, nextLink: Bool)
case expandThread(childThreads: [ConversationNode], inline: Bool)
case loadingIndicator
static func ==(lhs: Item, rhs: Item) -> 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
case (.loadingIndicator, .loadingIndicator):
return true
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)
case .loadingIndicator:
hasher.combine(2)
}
}
}
}
extension ConversationCollectionViewController: UICollectionViewDelegate {
func collectionView(_ collectionView: UICollectionView, shouldSelectItemAt indexPath: IndexPath) -> Bool {
switch dataSource.itemIdentifier(for: indexPath) {
case .status(id: let id, state: _, prevLink: _, nextLink: _):
return id != mainStatusID
case .expandThread(childThreads: _, inline: _):
return true
default:
return false
}
}
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
switch dataSource.itemIdentifier(for: indexPath) {
case nil:
break
case .loadingIndicator:
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
}
}

View File

@ -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<Section, Item>!
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<Section, Item>(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<Section, Item>()
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<Section, Item>()
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> = 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<Section, Item>) {
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..<pivotIndex].sort(by: { $0.status.createdAt < $1.status.createdAt })
childThreads[pivotIndex...].sort(by: { $0.status.createdAt < $1.status.createdAt })
for node in childThreads {
snapshot.appendSections([.childThread(firstStatusID: node.status.id)])
snapshot.appendItems([.status(id: node.status.id, state: .unknown)])
var currentNode = node
while true {
let next: ConversationNode
if currentNode.children.count == 0 {
break
} else if currentNode.children.count == 1 {
next = currentNode.children[0]
} else {
let sameAuthorStatuses = currentNode.children.filter({ $0.status.account.id == node.status.account.id })
if sameAuthorStatuses.count == 1 {
next = sameAuthorStatuses[0]
let nonSameAuthorChildren = currentNode.children.filter { $0.status.id != sameAuthorStatuses[0].status.id }
snapshot.appendItems([.expandThread(childThreads: nonSameAuthorChildren, inline: true)])
} else {
snapshot.appendItems([.expandThread(childThreads: currentNode.children, inline: false)])
break
}
}
currentNode = next
snapshot.appendItems([.status(id: next.status.id, state: .unknown)])
}
}
}
func item(for indexPath: IndexPath) -> (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 {
}

View File

@ -8,20 +8,16 @@
import UIKit
import Pachyderm
import WebURL
import WebURLFoundationExtras
class ConversationViewController: UIViewController {
weak var mastodonController: MastodonController!
let mainStatusID: String
private(set) var mode: Mode
let mainStatusState: CollapseState
var statusIDToScrollToOnLoad: String {
didSet {
if case .displaying(let vc) = state {
vc.statusIDToScrollToOnLoad = statusIDToScrollToOnLoad
}
}
}
var statusIDToScrollToOnLoad: String?
var showStatusesAutomatically = false {
didSet {
if case .displaying(let vc) = state {
@ -57,6 +53,8 @@ class ConversationViewController: UIViewController {
embedChild(vc)
case .notFound:
showMainStatusNotFound()
case .unableToResolve(let error):
showUnableToResolve(error)
}
updateVisibilityBarButtonItem()
@ -64,9 +62,16 @@ class ConversationViewController: UIViewController {
}
init(for mainStatusID: String, state mainStatusState: CollapseState, mastodonController: MastodonController) {
self.mainStatusID = mainStatusID
self.mode = .localID(mainStatusID)
self.mainStatusState = mainStatusState
self.statusIDToScrollToOnLoad = mainStatusID
self.mastodonController = mastodonController
super.init(nibName: nil, bundle: nil)
}
init(resolving url: URL, mastodonController: MastodonController) {
self.mode = .resolve(url)
self.mainStatusState = .unknown
self.mastodonController = mastodonController
super.init(nibName: nil, bundle: nil)
@ -121,7 +126,8 @@ class ConversationViewController: UIViewController {
guard let userInfo = notification.userInfo,
let accountID = mastodonController.accountInfo?.id,
userInfo["accountID"] as? String == accountID,
let statusIDs = userInfo["statusIDs"] as? [String] else {
let statusIDs = userInfo["statusIDs"] as? [String],
case .localID(let mainStatusID) = mode else {
return
}
if statusIDs.contains(mainStatusID) {
@ -137,6 +143,10 @@ class ConversationViewController: UIViewController {
// MARK: Loading
private func loadMainStatus() async {
guard let mainStatusID = await resolveStatusIfNecessary() else {
return
}
@MainActor
func doLoadMainStatus() async -> StatusMO? {
switch await FetchStatusService(statusID: mainStatusID, mastodonController: mastodonController).run() {
@ -169,10 +179,37 @@ class ConversationViewController: UIViewController {
}
}
@MainActor
private func resolveStatusIfNecessary() async -> String? {
switch mode {
case .localID(let id):
return id
case .resolve(let url):
let indicator = UIActivityIndicatorView(style: .medium)
indicator.startAnimating()
state = .loading(indicator)
let url = WebURL(url)!
let request = Client.search(query: url.serialized(), types: [.statuses], resolve: true)
do {
let (results, _) = try await mastodonController.run(request)
guard let status = results.statuses.first(where: { $0.url == url }) else {
throw UnableToResolveError()
}
_ = mastodonController.persistentContainer.addOrUpdateOnViewContext(status: status)
mode = .localID(status.id)
return status.id
} catch {
state = .unableToResolve(error)
return nil
}
}
}
@MainActor
private func mainStatusLoaded(_ mainStatus: StatusMO) async {
let vc = ConversationTableViewController(for: mainStatusID, state: mainStatusState, mastodonController: mastodonController)
vc.statusIDToScrollToOnLoad = statusIDToScrollToOnLoad
let vc = ConversationCollectionViewController(for: mainStatus.id, state: mainStatusState, mastodonController: mastodonController)
vc.statusIDToScrollToOnLoad = statusIDToScrollToOnLoad ?? mainStatus.id
vc.showStatusesAutomatically = showStatusesAutomatically
vc.addMainStatus(mainStatus)
state = .displaying(vc)
@ -227,6 +264,66 @@ class ConversationViewController: UIViewController {
self.showToast(configuration: config, animated: true)
}
private func showUnableToResolve(_ error: Error) {
let image = UIImageView(image: UIImage(systemName: "exclamationmark.triangle.fill")!)
image.tintColor = .secondaryLabel
image.contentMode = .scaleAspectFit
let title = UILabel()
title.textColor = .secondaryLabel
title.font = .preferredFont(forTextStyle: .title1).withTraits(.traitBold)!
title.adjustsFontForContentSizeCategory = true
title.text = "Couldn't Load Post"
let subtitle = UILabel()
subtitle.textColor = .secondaryLabel
subtitle.font = .preferredFont(forTextStyle: .body)
subtitle.adjustsFontForContentSizeCategory = true
subtitle.numberOfLines = 0
subtitle.textAlignment = .center
if let error = error as? UnableToResolveError {
subtitle.text = error.localizedDescription
} else if let error = error as? Client.Error {
subtitle.text = error.localizedDescription
} else {
subtitle.text = error.localizedDescription
}
var config = UIButton.Configuration.plain()
config.title = "Open in Safari"
config.image = UIImage(systemName: "safari")
config.imagePadding = 4
let button = UIButton(configuration: config, primaryAction: UIAction(handler: { [unowned self] _ in
guard case .resolve(let url) = self.mode else {
return
}
self.selected(url: url, allowResolveStatuses: false)
}))
let stack = UIStackView(arrangedSubviews: [
image,
title,
subtitle,
button,
])
stack.axis = .vertical
stack.alignment = .center
stack.spacing = 8
stack.isAccessibilityElement = true
stack.accessibilityLabel = "\(title.text!). \(subtitle.text!)"
stack.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(stack)
NSLayoutConstraint.activate([
image.widthAnchor.constraint(equalToConstant: 64),
image.heightAnchor.constraint(equalToConstant: 64),
stack.leadingAnchor.constraint(equalToSystemSpacingAfter: view.safeAreaLayoutGuide.leadingAnchor, multiplier: 1),
view.safeAreaLayoutGuide.trailingAnchor.constraint(equalToSystemSpacingAfter: stack.trailingAnchor, multiplier: 1),
stack.centerYAnchor.constraint(equalTo: view.safeAreaLayoutGuide.centerYAnchor),
])
}
// MARK: Interaction
@objc func toggleCollapseButtonPressed() {
@ -240,15 +337,35 @@ class ConversationViewController: UIViewController {
}
extension ConversationViewController {
enum Mode {
case localID(String)
case resolve(URL)
}
}
extension ConversationViewController {
struct UnableToResolveError: Error {
var localizedDescription: String {
"Unable to resolve status from URL"
}
}
}
extension ConversationViewController {
enum State {
case unloaded
case loading(UIActivityIndicatorView)
case displaying(ConversationTableViewController)
case displaying(ConversationCollectionViewController)
case notFound
case unableToResolve(Error)
}
}
extension ConversationViewController: TuskerNavigationDelegate {
var apiController: MastodonController! { mastodonController }
}
extension ConversationViewController: ToastableViewController {
var toastScrollView: UIScrollView? {
if case .displaying(let vc) = state {

View File

@ -1,5 +1,5 @@
//
// ExpandThreadTableViewCell.swift
// ExpandThreadCollectionViewCell.swift
// Tusker
//
// Created by Shadowfacts on 1/30/21.
@ -8,62 +8,92 @@
import UIKit
class ExpandThreadTableViewCell: UITableViewCell {
class ExpandThreadCollectionViewCell: UICollectionViewListCell {
private var avatarContainerView: UIView!
private var avatarContainerWidthConstraint: NSLayoutConstraint!
private var replyCountLabel: UILabel!
private var hStack: UIStackView!
private var stackViewLeadingConstraint: NSLayoutConstraint!
@IBOutlet weak var stackViewLeadingConstraint: NSLayoutConstraint!
@IBOutlet weak var avatarContainerView: UIView!
@IBOutlet weak var avatarContainerWidthConstraint: NSLayoutConstraint!
@IBOutlet weak var replyCountLabel: UILabel!
private var threadLinkView: UIView!
private var threadLinkViewFullHeightConstraint: NSLayoutConstraint!
private var threadLinkViewShortHeightConstraint: NSLayoutConstraint!
private var avatarImageViews: [UIImageView] = []
private var avatarRequests: [ImageCache.Request] = []
override init(frame: CGRect) {
super.init(frame: frame)
override func awakeFromNib() {
super.awakeFromNib()
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)
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 +101,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,7 +111,6 @@ 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)
@ -98,32 +127,32 @@ 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() }
override func updateConfiguration(using state: UICellConfigurationState) {
var config = UIBackgroundConfiguration.listPlainCell()
if state.isSelected || state.isHighlighted {
var hue: CGFloat = 0
var saturation: CGFloat = 0
var brightness: CGFloat = 0
UIColor.secondarySystemBackground.getHue(&hue, saturation: &saturation, brightness: &brightness, alpha: nil)
let sign: CGFloat = traitCollection.userInterfaceStyle == .dark ? 1 : -1
config.backgroundColor = UIColor(hue: hue, saturation: saturation, brightness: max(0, brightness + sign * 0.1), alpha: 1)
} else {
config.backgroundColor = .secondarySystemBackground
}
backgroundConfiguration = config.updated(for: state)
}
}

View File

@ -1,81 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="21507" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
<device id="retina6_1" orientation="portrait" appearance="light"/>
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="21505"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="System colors in document resources" minToolsVersion="11.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<objects>
<placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner"/>
<placeholder placeholderIdentifier="IBFirstResponder" id="-2" customClass="UIResponder"/>
<tableViewCell contentMode="scaleToFill" selectionStyle="default" indentationWidth="10" id="KGk-i7-Jjw" customClass="ExpandThreadTableViewCell" customModule="Tusker" customModuleProvider="target">
<rect key="frame" x="0.0" y="0.0" width="320" height="44"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" layoutMarginsFollowReadableWidth="YES" tableViewCell="KGk-i7-Jjw" id="H2p-sc-9uM">
<rect key="frame" x="0.0" y="0.0" width="320" height="44"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<stackView opaque="NO" contentMode="scaleToFill" alignment="center" spacing="8" translatesAutoresizingMaskIntoConstraints="NO" id="IXi-sc-YIw">
<rect key="frame" x="16" y="0.0" width="173" height="44"/>
<subviews>
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="eFB-F1-d3A">
<rect key="frame" x="0.0" y="6" width="100" height="32"/>
<constraints>
<constraint firstAttribute="height" constant="32" id="g9U-u7-718"/>
<constraint firstAttribute="width" constant="100" id="tiI-Rj-gjh"/>
</constraints>
</view>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="2 replies" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="Dcm-ll-GeE">
<rect key="frame" x="108" y="12" width="65" height="20.5"/>
<fontDescription key="fontDescription" type="system" pointSize="17"/>
<color key="textColor" systemColor="tintColor"/>
<nil key="highlightedColor"/>
</label>
</subviews>
<constraints>
<constraint firstAttribute="height" constant="44" id="jk2-uV-FdO"/>
</constraints>
</stackView>
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="Kkt-hM-ScW">
<rect key="frame" x="16" y="43.5" width="288" height="0.5"/>
<color key="backgroundColor" systemColor="separatorColor"/>
<constraints>
<constraint firstAttribute="height" constant="0.5" id="Fkq-bT-IYv"/>
</constraints>
</view>
</subviews>
<constraints>
<constraint firstAttribute="bottom" secondItem="Kkt-hM-ScW" secondAttribute="bottom" id="AvY-H1-0YN"/>
<constraint firstItem="Kkt-hM-ScW" firstAttribute="leading" secondItem="H2p-sc-9uM" secondAttribute="leadingMargin" id="E5g-hz-SLI"/>
<constraint firstItem="IXi-sc-YIw" firstAttribute="top" secondItem="H2p-sc-9uM" secondAttribute="top" id="SRF-Zx-Y0R"/>
<constraint firstAttribute="trailingMargin" secondItem="Kkt-hM-ScW" secondAttribute="trailing" id="YML-R1-ezq"/>
<constraint firstItem="IXi-sc-YIw" firstAttribute="leading" secondItem="H2p-sc-9uM" secondAttribute="leadingMargin" id="iD5-Av-ORS"/>
<constraint firstAttribute="bottom" secondItem="IXi-sc-YIw" secondAttribute="bottom" id="kpD-6Q-qKi"/>
</constraints>
</tableViewCellContentView>
<viewLayoutGuide key="safeArea" id="njF-e1-oar"/>
<color key="backgroundColor" systemColor="secondarySystemBackgroundColor"/>
<connections>
<outlet property="avatarContainerView" destination="eFB-F1-d3A" id="xGo-40-nn7"/>
<outlet property="avatarContainerWidthConstraint" destination="tiI-Rj-gjh" id="34n-ev-EKi"/>
<outlet property="replyCountLabel" destination="Dcm-ll-GeE" id="E4m-xk-DiQ"/>
<outlet property="stackViewLeadingConstraint" destination="iD5-Av-ORS" id="Try-cG-8uA"/>
</connections>
<point key="canvasLocation" x="132" y="132"/>
</tableViewCell>
</objects>
<resources>
<systemColor name="secondarySystemBackgroundColor">
<color red="0.94901960784313721" green="0.94901960784313721" blue="0.96862745098039216" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
</systemColor>
<systemColor name="separatorColor">
<color red="0.23529411764705882" green="0.23529411764705882" blue="0.2627450980392157" alpha="0.28999999999999998" colorSpace="custom" customColorSpace="sRGB"/>
</systemColor>
<systemColor name="tintColor">
<color red="0.0" green="0.47843137254901963" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
</systemColor>
</resources>
</document>

View File

@ -44,7 +44,7 @@ extension TuskerNavigationDelegate {
show(HashtagTimelineViewController(for: tag, mastodonController: apiController), sender: self)
}
func selected(url: URL, allowUniversalLinks: Bool = true) {
func selected(url: URL, allowResolveStatuses: Bool = true, allowUniversalLinks: Bool = true) {
func openSafari() {
if Preferences.shared.useInAppSafari,
url.scheme == "https" || url.scheme == "http" {
@ -66,8 +66,12 @@ extension TuskerNavigationDelegate {
}
}
if allowUniversalLinks && Preferences.shared.openLinksInApps,
url.scheme == "https" || url.scheme == "http" {
if allowResolveStatuses,
isLikelyResolvableAsStatus(url) {
show(ConversationViewController(resolving: url, mastodonController: apiController))
} else if allowUniversalLinks,
Preferences.shared.openLinksInApps,
url.scheme == "https" || url.scheme == "http" {
UIApplication.shared.open(url, options: [.universalLinksOnly: true]) { (success) in
if (!success) {
openSafari()
@ -217,3 +221,21 @@ enum PopoverSource {
.barButtonItem(WeakHolder(item))
}
}
private let statusPathRegex = try! NSRegularExpression(
pattern:
"(^/@[a-z0-9_]+/\\d{18})" // mastodon
+ "|(^/notice/[a-z0-9]{18})" // pleroma
+ "|(^/p/[a-z0-9_]+/\\d{18})" // pixelfed
+ "|(^/i/web/post/\\d{18})" // pixelfed web frontend
+ "|(^/u/.+/h/[a-z0-9]{18})" // honk
+ "|(^/@.+/statuses/[a-z0-9]{26})" // gotosocial
,
options: .caseInsensitive
)
private func isLikelyResolvableAsStatus(_ url: URL) -> Bool {
let path = url.path
let range = NSRange(location: 0, length: path.utf16.count)
return statusPathRegex.numberOfMatches(in: path, range: range) == 1
}

View File

@ -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<AnyCancellable>()
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
}
}

View File

@ -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)) ?? [])
}
}
}

View File

@ -1,293 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="21507" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
<device id="retina4_7" orientation="portrait" appearance="light"/>
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="21505"/>
<capability name="Image references" minToolsVersion="12.0"/>
<capability name="System colors in document resources" minToolsVersion="11.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<objects>
<placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner"/>
<placeholder placeholderIdentifier="IBFirstResponder" id="-2" customClass="UIResponder"/>
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="default" indentationWidth="10" rowHeight="304" id="IDI-ur-8pa" customClass="ConversationMainStatusTableViewCell" customModule="Tusker" customModuleProvider="target">
<rect key="frame" x="0.0" y="0.0" width="393" height="304"/>
<autoresizingMask key="autoresizingMask"/>
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="IDI-ur-8pa" id="MkV-Jo-zuv">
<rect key="frame" x="0.0" y="0.0" width="393" height="304"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<stackView opaque="NO" contentMode="scaleToFill" axis="vertical" alignment="top" spacing="8" translatesAutoresizingMaskIntoConstraints="NO" id="GuG-Qd-B8I">
<rect key="frame" x="16" y="8" width="361" height="288"/>
<subviews>
<view contentMode="scaleToFill" verticalHuggingPriority="249" translatesAutoresizingMaskIntoConstraints="NO" id="Cnd-Fj-B7l">
<rect key="frame" x="0.0" y="0.0" width="361" height="50"/>
<subviews>
<imageView contentMode="scaleToFill" horizontalHuggingPriority="251" verticalHuggingPriority="251" translatesAutoresizingMaskIntoConstraints="NO" id="mB9-HO-1vf">
<rect key="frame" x="0.0" y="0.0" width="50" height="50"/>
<constraints>
<constraint firstAttribute="height" constant="50" id="XPF-UL-68q"/>
<constraint firstAttribute="width" constant="50" id="Yxp-Vr-dfl"/>
</constraints>
</imageView>
<label opaque="NO" contentMode="left" horizontalHuggingPriority="249" verticalHuggingPriority="251" text="Display name" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" minimumFontSize="12" adjustsLetterSpacingToFitWidth="YES" translatesAutoresizingMaskIntoConstraints="NO" id="lZY-2e-17d" customClass="EmojiLabel" customModule="Tusker" customModuleProvider="target">
<rect key="frame" x="58" y="0.0" width="146.5" height="29"/>
<fontDescription key="fontDescription" type="system" weight="semibold" pointSize="24"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
<label opaque="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="@username" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="SWg-Ka-QyP">
<rect key="frame" x="58" y="29" width="303" height="20.5"/>
<fontDescription key="fontDescription" type="system" weight="light" pointSize="17"/>
<color key="textColor" systemColor="secondaryLabelColor"/>
<nil key="highlightedColor"/>
</label>
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="xD6-dy-0XV" customClass="StatusMetaIndicatorsView" customModule="Tusker" customModuleProvider="target">
<rect key="frame" x="212.5" y="0.0" width="148.5" height="22"/>
<color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<constraints>
<constraint firstAttribute="height" constant="22" placeholder="YES" id="wF5-Ii-LO5"/>
</constraints>
</view>
</subviews>
<color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<constraints>
<constraint firstAttribute="trailing" secondItem="SWg-Ka-QyP" secondAttribute="trailing" id="4g6-BT-eW4"/>
<constraint firstItem="xD6-dy-0XV" firstAttribute="top" secondItem="Cnd-Fj-B7l" secondAttribute="top" id="7Io-sX-c9k"/>
<constraint firstItem="lZY-2e-17d" firstAttribute="top" secondItem="Cnd-Fj-B7l" secondAttribute="top" id="8fU-y9-K5Z"/>
<constraint firstItem="lZY-2e-17d" firstAttribute="leading" secondItem="mB9-HO-1vf" secondAttribute="trailing" constant="8" id="Aqj-co-Szp"/>
<constraint firstItem="xD6-dy-0XV" firstAttribute="leading" secondItem="lZY-2e-17d" secondAttribute="trailing" constant="8" id="PfV-YZ-k9j"/>
<constraint firstItem="mB9-HO-1vf" firstAttribute="top" secondItem="Cnd-Fj-B7l" secondAttribute="top" id="R7P-rD-Gbm"/>
<constraint firstAttribute="bottom" secondItem="mB9-HO-1vf" secondAttribute="bottom" id="Wd0-Qh-idS"/>
<constraint firstItem="mB9-HO-1vf" firstAttribute="leading" secondItem="Cnd-Fj-B7l" secondAttribute="leading" id="bxq-Fs-1aH"/>
<constraint firstItem="SWg-Ka-QyP" firstAttribute="leading" secondItem="mB9-HO-1vf" secondAttribute="trailing" constant="8" id="e45-gE-myI"/>
<constraint firstItem="SWg-Ka-QyP" firstAttribute="top" secondItem="lZY-2e-17d" secondAttribute="bottom" id="lvX-1b-8cN"/>
<constraint firstAttribute="trailing" secondItem="xD6-dy-0XV" secondAttribute="trailing" id="tfq-dR-UT7"/>
</constraints>
</view>
<label opaque="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="252" verticalCompressionResistancePriority="751" text="Content Warning" textAlignment="natural" lineBreakMode="tailTruncation" numberOfLines="0" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="cwQ-mR-L1b" customClass="EmojiLabel" customModule="Tusker" customModuleProvider="target">
<rect key="frame" x="0.0" y="58" width="361" height="20.5"/>
<accessibility key="accessibilityConfiguration">
<accessibilityTraits key="traits" staticText="YES" notEnabled="YES"/>
</accessibility>
<fontDescription key="fontDescription" type="boldSystem" pointSize="17"/>
<color key="textColor" systemColor="secondaryLabelColor"/>
<nil key="highlightedColor"/>
</label>
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="8r8-O8-Agh" customClass="StatusCollapseButton" customModule="Tusker" customModuleProvider="target">
<rect key="frame" x="0.0" y="86.5" width="361" height="30"/>
<color key="backgroundColor" systemColor="tintColor"/>
<constraints>
<constraint firstAttribute="height" constant="30" id="icD-3q-uJ6"/>
</constraints>
<color key="tintColor" systemColor="tintColor"/>
<state key="normal" image="chevron.down" catalog="system">
<preferredSymbolConfiguration key="preferredSymbolConfiguration" scale="large"/>
</state>
<buttonConfiguration key="configuration" style="filled" image="chevron.down" catalog="system">
<preferredSymbolConfiguration key="preferredSymbolConfigurationForImage" scale="large"/>
<color key="baseForegroundColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
</buttonConfiguration>
<connections>
<action selector="collapseButtonPressed" destination="IDI-ur-8pa" eventType="touchUpInside" id="00b-nM-U5g"/>
</connections>
</button>
<textView clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="scaleToFill" verticalCompressionResistancePriority="749" scrollEnabled="NO" delaysContentTouches="NO" editable="NO" translatesAutoresizingMaskIntoConstraints="NO" id="z0g-HN-gS0" customClass="StatusContentTextView" customModule="Tusker" customModuleProvider="target">
<rect key="frame" x="0.0" y="124.5" width="361" height="2.5"/>
<string key="text">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.</string>
<color key="textColor" systemColor="labelColor"/>
<fontDescription key="fontDescription" type="system" pointSize="20"/>
<textInputTraits key="textInputTraits" autocapitalizationType="sentences"/>
</textView>
<view hidden="YES" contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="QqC-GR-TLC" customClass="StatusCardView" customModule="Tusker" customModuleProvider="target">
<rect key="frame" x="0.0" y="131" width="361" height="0.0"/>
<color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<constraints>
<constraint firstAttribute="height" priority="999" constant="90" id="Tdo-Hv-ITE"/>
</constraints>
</view>
<view hidden="YES" contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="IF9-9U-Gk0" customClass="AttachmentsContainerView" customModule="Tusker" customModuleProvider="target">
<rect key="frame" x="0.0" y="131" width="361" height="0.0"/>
<color key="backgroundColor" systemColor="secondarySystemBackgroundColor"/>
</view>
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="TLv-Xu-tT1" customClass="StatusPollView" customModule="Tusker" customModuleProvider="target">
<rect key="frame" x="0.0" y="135" width="361" height="50"/>
<color key="backgroundColor" systemColor="systemBackgroundColor"/>
</view>
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="ejU-sO-Og5">
<rect key="frame" x="0.0" y="193" width="361" height="0.5"/>
<color key="backgroundColor" systemColor="opaqueSeparatorColor"/>
<constraints>
<constraint firstAttribute="height" constant="0.5" id="DRI-lB-TyG"/>
</constraints>
</view>
<stackView opaque="NO" contentMode="scaleToFill" spacing="8" translatesAutoresizingMaskIntoConstraints="NO" id="HZv-qj-gi6">
<rect key="frame" x="0.0" y="201.5" width="142" height="18"/>
<subviews>
<button opaque="NO" contentMode="scaleToFill" verticalHuggingPriority="252" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="system" lineBreakMode="middleTruncation" pointerInteraction="YES" translatesAutoresizingMaskIntoConstraints="NO" id="yyj-Bs-Vjq">
<rect key="frame" x="0.0" y="0.0" width="75" height="18"/>
<constraints>
<constraint firstAttribute="height" constant="18" id="F9W-LW-swd"/>
</constraints>
<state key="normal" title="2 Favorites">
<color key="titleColor" systemColor="secondaryLabelColor"/>
</state>
<connections>
<action selector="totalFavoritesPressed" destination="IDI-ur-8pa" eventType="touchUpInside" id="QEe-yA-n91"/>
</connections>
</button>
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="system" lineBreakMode="middleTruncation" pointerInteraction="YES" translatesAutoresizingMaskIntoConstraints="NO" id="dem-vG-cPB">
<rect key="frame" x="83" y="0.0" width="59" height="18"/>
<constraints>
<constraint firstAttribute="height" constant="18" id="k0P-W7-wMF"/>
</constraints>
<state key="normal" title="1 Reblog">
<color key="titleColor" systemColor="secondaryLabelColor"/>
</state>
<connections>
<action selector="totalReblogsPressed" destination="IDI-ur-8pa" eventType="touchUpInside" id="Rmo-Mm-z1A"/>
</connections>
</button>
</subviews>
</stackView>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Sep 7, 2019 12:12:53 PM • Web" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="YHN-wG-YWi">
<rect key="frame" x="0.0" y="227.5" width="213.5" height="18"/>
<accessibility key="accessibilityConfiguration">
<accessibilityTraits key="traits" staticText="YES" notEnabled="YES"/>
</accessibility>
<fontDescription key="fontDescription" type="system" pointSize="15"/>
<color key="textColor" systemColor="secondaryLabelColor"/>
<nil key="highlightedColor"/>
</label>
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="3Fp-Nj-sVj">
<rect key="frame" x="0.0" y="253.5" width="361" height="0.5"/>
<color key="backgroundColor" systemColor="opaqueSeparatorColor"/>
<constraints>
<constraint firstAttribute="height" constant="0.5" id="akf-Kl-8mK"/>
</constraints>
</view>
<stackView opaque="NO" contentMode="scaleToFill" horizontalHuggingPriority="251" verticalHuggingPriority="251" distribution="fillEqually" translatesAutoresizingMaskIntoConstraints="NO" id="3Bg-XP-d13">
<rect key="frame" x="0.0" y="262" width="361" height="26"/>
<subviews>
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" pointerInteraction="YES" translatesAutoresizingMaskIntoConstraints="NO" id="2cc-lE-AdG">
<rect key="frame" x="0.0" y="0.0" width="90.5" height="26"/>
<accessibility key="accessibilityConfiguration" label="Reply"/>
<color key="tintColor" systemColor="tintColor"/>
<state key="normal">
<imageReference key="image" image="arrowshape.turn.up.left.fill" catalog="system" symbolScale="large"/>
</state>
<connections>
<action selector="replyPressed" destination="IDI-ur-8pa" eventType="touchUpInside" id="Wic-n9-0Tt"/>
</connections>
</button>
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" pointerInteraction="YES" translatesAutoresizingMaskIntoConstraints="NO" id="DhN-rJ-jdA">
<rect key="frame" x="90.5" y="0.0" width="90" height="26"/>
<accessibility key="accessibilityConfiguration" label="Favorite"/>
<color key="tintColor" systemColor="tintColor"/>
<state key="normal">
<imageReference key="image" image="star.fill" catalog="system" symbolScale="large"/>
</state>
<connections>
<action selector="favoritePressed" destination="IDI-ur-8pa" eventType="touchUpInside" id="GjO-Fw-3tF"/>
</connections>
</button>
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" pointerInteraction="YES" translatesAutoresizingMaskIntoConstraints="NO" id="GUG-f7-Hdy">
<rect key="frame" x="180.5" y="0.0" width="90.5" height="26"/>
<accessibility key="accessibilityConfiguration" label="Reblog"/>
<color key="tintColor" systemColor="tintColor"/>
<state key="normal">
<imageReference key="image" image="repeat" catalog="system" symbolScale="large"/>
</state>
<connections>
<action selector="reblogPressed" destination="IDI-ur-8pa" eventType="touchUpInside" id="iLg-O3-i33"/>
</connections>
</button>
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" pointerInteraction="YES" translatesAutoresizingMaskIntoConstraints="NO" id="Ujo-Ap-dmK">
<rect key="frame" x="271" y="0.0" width="90" height="26"/>
<accessibility key="accessibilityConfiguration" label="More Actions"/>
<color key="tintColor" systemColor="tintColor"/>
<state key="normal">
<imageReference key="image" image="ellipsis" catalog="system" symbolScale="large"/>
</state>
<connections>
<action selector="morePressed" destination="IDI-ur-8pa" eventType="touchUpInside" id="JNt-fh-WYW"/>
</connections>
</button>
</subviews>
<constraints>
<constraint firstAttribute="height" constant="26" id="bqe-m8-5Lo"/>
</constraints>
</stackView>
</subviews>
<constraints>
<constraint firstItem="QqC-GR-TLC" firstAttribute="width" secondItem="GuG-Qd-B8I" secondAttribute="width" id="2WL-jD-I09"/>
<constraint firstItem="Cnd-Fj-B7l" firstAttribute="width" secondItem="GuG-Qd-B8I" secondAttribute="width" id="2hS-RG-81T"/>
<constraint firstItem="z0g-HN-gS0" firstAttribute="width" secondItem="GuG-Qd-B8I" secondAttribute="width" id="4TF-2Z-mdf"/>
<constraint firstItem="IF9-9U-Gk0" firstAttribute="width" secondItem="GuG-Qd-B8I" secondAttribute="width" id="8A8-wi-7sg"/>
<constraint firstItem="cwQ-mR-L1b" firstAttribute="width" secondItem="GuG-Qd-B8I" secondAttribute="width" id="O32-3Q-mUs"/>
<constraint firstItem="8r8-O8-Agh" firstAttribute="width" secondItem="GuG-Qd-B8I" secondAttribute="width" id="bZv-bR-jJ3"/>
<constraint firstItem="ejU-sO-Og5" firstAttribute="width" secondItem="GuG-Qd-B8I" secondAttribute="width" id="biK-oQ-SLy"/>
<constraint firstItem="3Bg-XP-d13" firstAttribute="width" secondItem="GuG-Qd-B8I" secondAttribute="width" id="iIq-gh-90O"/>
<constraint firstItem="3Fp-Nj-sVj" firstAttribute="width" secondItem="GuG-Qd-B8I" secondAttribute="width" id="kfI-WN-ouW"/>
<constraint firstItem="TLv-Xu-tT1" firstAttribute="width" secondItem="GuG-Qd-B8I" secondAttribute="width" id="v87-hd-fd4"/>
</constraints>
</stackView>
</subviews>
<constraints>
<constraint firstAttribute="trailingMargin" secondItem="GuG-Qd-B8I" secondAttribute="trailing" id="GP2-Xt-d67"/>
<constraint firstAttribute="bottom" secondItem="GuG-Qd-B8I" secondAttribute="bottom" constant="8" id="Il3-lH-xux"/>
<constraint firstItem="GuG-Qd-B8I" firstAttribute="leading" secondItem="MkV-Jo-zuv" secondAttribute="leadingMargin" id="irk-Qi-Wqw"/>
<constraint firstItem="GuG-Qd-B8I" firstAttribute="top" secondItem="MkV-Jo-zuv" secondAttribute="top" constant="8" id="w4H-TR-7gK"/>
</constraints>
</tableViewCellContentView>
<connections>
<outlet property="attachmentsView" destination="IF9-9U-Gk0" id="UeF-4a-G6k"/>
<outlet property="avatarImageView" destination="mB9-HO-1vf" id="GAt-8i-MIE"/>
<outlet property="cardView" destination="QqC-GR-TLC" id="gkB-5d-ute"/>
<outlet property="collapseButton" destination="8r8-O8-Agh" id="kLb-x0-Qx9"/>
<outlet property="contentTextView" destination="z0g-HN-gS0" id="ILZ-WW-zbU"/>
<outlet property="contentWarningLabel" destination="cwQ-mR-L1b" id="JN1-VC-Fql"/>
<outlet property="displayNameLabel" destination="lZY-2e-17d" id="FFF-kc-1q2"/>
<outlet property="favoriteAndReblogCountStackView" destination="HZv-qj-gi6" id="KGA-as-022"/>
<outlet property="favoriteButton" destination="DhN-rJ-jdA" id="f6b-VR-dsA"/>
<outlet property="metaIndicatorsView" destination="xD6-dy-0XV" id="sx9-Pf-Qox"/>
<outlet property="moreButton" destination="Ujo-Ap-dmK" id="tBm-jm-2FR"/>
<outlet property="pollView" destination="TLv-Xu-tT1" id="l9J-Tv-ndf"/>
<outlet property="profileDetailContainerView" destination="Cnd-Fj-B7l" id="32j-B2-ueG"/>
<outlet property="reblogButton" destination="GUG-f7-Hdy" id="ZRh-Qy-HXG"/>
<outlet property="replyButton" destination="2cc-lE-AdG" id="EBZ-RJ-Qbp"/>
<outlet property="timestampAndClientLabel" destination="YHN-wG-YWi" id="ewe-Ad-dYR"/>
<outlet property="totalFavoritesButton" destination="yyj-Bs-Vjq" id="m4k-RB-lM0"/>
<outlet property="totalReblogsButton" destination="dem-vG-cPB" id="AHj-6A-5qm"/>
<outlet property="usernameLabel" destination="SWg-Ka-QyP" id="OFL-Sc-OQX"/>
</connections>
<point key="canvasLocation" x="-631.20000000000005" y="-109.74512743628186"/>
</tableViewCell>
</objects>
<resources>
<image name="arrowshape.turn.up.left.fill" catalog="system" width="128" height="104"/>
<image name="chevron.down" catalog="system" width="128" height="70"/>
<image name="ellipsis" catalog="system" width="128" height="37"/>
<image name="repeat" catalog="system" width="128" height="98"/>
<image name="star.fill" catalog="system" width="128" height="116"/>
<systemColor name="labelColor">
<color red="0.0" green="0.0" blue="0.0" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
</systemColor>
<systemColor name="opaqueSeparatorColor">
<color red="0.77647058823529413" green="0.77647058823529413" blue="0.78431372549019607" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
</systemColor>
<systemColor name="secondaryLabelColor">
<color red="0.23529411764705882" green="0.23529411764705882" blue="0.2627450980392157" alpha="0.59999999999999998" colorSpace="custom" customColorSpace="sRGB"/>
</systemColor>
<systemColor name="secondarySystemBackgroundColor">
<color red="0.94901960784313721" green="0.94901960784313721" blue="0.96862745098039216" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
</systemColor>
<systemColor name="systemBackgroundColor">
<color white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
</systemColor>
<systemColor name="tintColor">
<color red="0.0" green="0.47843137254901963" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
</systemColor>
</resources>
</document>

View File

@ -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<AnyCancellable> { 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

View File

@ -10,7 +10,14 @@ import UIKit
class StatusContentContainer: UIView {
let contentTextView = StatusContentTextView().configure {
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 {
$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

View File

@ -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 {