Compare commits

...

3 Commits

4 changed files with 202 additions and 23 deletions

View File

@ -94,6 +94,9 @@ class ConversationCollectionViewController: UIViewController, CollectionViewCont
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):
@ -104,17 +107,31 @@ class ConversationCollectionViewController: UIViewController, CollectionViewCont
}
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)
}
@ -125,6 +142,7 @@ class ConversationCollectionViewController: UIViewController, CollectionViewCont
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)
@ -299,6 +317,7 @@ extension ConversationCollectionViewController {
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) {
@ -306,6 +325,8 @@ extension ConversationCollectionViewController {
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
}
@ -324,6 +345,8 @@ extension ConversationCollectionViewController {
hasher.combine(thread.status.id)
}
hasher.combine(inline)
case .loadingIndicator:
hasher.combine(2)
}
}
}
@ -331,11 +354,13 @@ extension ConversationCollectionViewController {
extension ConversationCollectionViewController: UICollectionViewDelegate {
func collectionView(_ collectionView: UICollectionView, shouldSelectItemAt indexPath: IndexPath) -> Bool {
if case .status(id: let id, _, _, _) = dataSource.itemIdentifier(for: indexPath),
id == mainStatusID {
return false
} else {
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
}
}
@ -343,6 +368,8 @@ extension ConversationCollectionViewController: UICollectionViewDelegate {
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: _):

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 = ConversationCollectionViewController(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(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

@ -8,7 +8,7 @@
import UIKit
class ExpandThreadCollectionViewCell: UICollectionViewCell {
class ExpandThreadCollectionViewCell: UICollectionViewListCell {
private var avatarContainerView: UIView!
private var avatarContainerWidthConstraint: NSLayoutConstraint!
@ -46,8 +46,6 @@ class ExpandThreadCollectionViewCell: UICollectionViewCell {
contentView.addSubview(hStack)
stackViewLeadingConstraint = hStack.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16)
// TODO: separator
threadLinkView = UIView()
threadLinkView.backgroundColor = .tintColor.withAlphaComponent(0.5)
threadLinkView.layer.cornerRadius = 2.5
@ -142,4 +140,19 @@ class ExpandThreadCollectionViewCell: UICollectionViewCell {
}
}
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

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