diff --git a/Tusker.xcodeproj/project.pbxproj b/Tusker.xcodeproj/project.pbxproj index 945a7e8c..1e302bf5 100644 --- a/Tusker.xcodeproj/project.pbxproj +++ b/Tusker.xcodeproj/project.pbxproj @@ -150,6 +150,8 @@ D65B4B58297203A700DABDFB /* ReportSelectRulesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D65B4B57297203A700DABDFB /* ReportSelectRulesView.swift */; }; D65B4B5A29720AB000DABDFB /* ReportStatusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D65B4B5929720AB000DABDFB /* ReportStatusView.swift */; }; D65B4B5E2973040D00DABDFB /* ReportAddStatusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D65B4B5D2973040D00DABDFB /* ReportAddStatusView.swift */; }; + D65B4B6229771A3F00DABDFB /* FetchStatusService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D65B4B6129771A3F00DABDFB /* FetchStatusService.swift */; }; + D65B4B6429771EFF00DABDFB /* ConversationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D65B4B6329771EFF00DABDFB /* ConversationViewController.swift */; }; D65C6BF525478A9C00A6E89C /* BackgroundableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D65C6BF425478A9C00A6E89C /* BackgroundableViewController.swift */; }; D65F613423AEAB6600F3CFD3 /* OnboardingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D65F613323AEAB6600F3CFD3 /* OnboardingTests.swift */; }; D6620ACE2511A0ED00312CA0 /* StatusStateResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6620ACD2511A0ED00312CA0 /* StatusStateResolver.swift */; }; @@ -537,6 +539,8 @@ D65B4B57297203A700DABDFB /* ReportSelectRulesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportSelectRulesView.swift; sourceTree = ""; }; D65B4B5929720AB000DABDFB /* ReportStatusView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportStatusView.swift; sourceTree = ""; }; D65B4B5D2973040D00DABDFB /* ReportAddStatusView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportAddStatusView.swift; sourceTree = ""; }; + D65B4B6129771A3F00DABDFB /* FetchStatusService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FetchStatusService.swift; sourceTree = ""; }; + D65B4B6329771EFF00DABDFB /* ConversationViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationViewController.swift; sourceTree = ""; }; D65C6BF425478A9C00A6E89C /* BackgroundableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundableViewController.swift; sourceTree = ""; }; D65F612D23AE990C00F3CFD3 /* Embassy.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = Embassy.framework; sourceTree = BUILT_PRODUCTS_DIR; }; D65F613023AE99E000F3CFD3 /* Ambassador.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = Ambassador.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -1033,6 +1037,7 @@ D641C785213DD83B004B4513 /* Conversation */ = { isa = PBXGroup; children = ( + D65B4B6329771EFF00DABDFB /* ConversationViewController.swift */, D667E5F42135BCD50057A976 /* ConversationTableViewController.swift */, D6C82B5425C5F3F20017F1E6 /* ExpandThreadTableViewCell.swift */, D6C82B5525C5F3F20017F1E6 /* ExpandThreadTableViewCell.xib */, @@ -1606,6 +1611,7 @@ D61F75B0293BD85300C0B37F /* CreateFilterService.swift */, D61F75B2293BD89C00C0B37F /* UpdateFilterService.swift */, D61F75B4293BD97400C0B37F /* DeleteFilterService.swift */, + D65B4B6129771A3F00DABDFB /* FetchStatusService.swift */, ); path = API; sourceTree = ""; @@ -2008,6 +2014,7 @@ D63CC7102911F1E4000E19DE /* UIScrollView+Top.swift in Sources */, D6F6A557291F4F1600F496A8 /* MuteAccountView.swift in Sources */, D6945C3A23AC75E2005C403C /* FindInstanceViewController.swift in Sources */, + D65B4B6429771EFF00DABDFB /* ConversationViewController.swift in Sources */, D68E6F59253C9969001A1B4C /* MultiSourceEmojiLabel.swift in Sources */, D63F9C6E241D2D85004C03CF /* CompositionAttachment.swift in Sources */, D6EBF01523C55C0900AE061B /* UIApplication+Scenes.swift in Sources */, @@ -2027,6 +2034,7 @@ D659F36229541065002D944A /* TTTView.swift in Sources */, D6C143DA253510F4007DC240 /* ComposeEmojiTextField.swift in Sources */, D6DD2A3F273C1F4900386A6C /* ComposeAttachmentImage.swift in Sources */, + D65B4B6229771A3F00DABDFB /* FetchStatusService.swift in Sources */, D6DD2A45273D6C5700386A6C /* GIFImageView.swift in Sources */, D61F759029353B4300C0B37F /* FileManager+Size.swift in Sources */, 0427033822B30F5F000D31B6 /* BehaviorPrefsView.swift in Sources */, diff --git a/Tusker/API/FetchStatusService.swift b/Tusker/API/FetchStatusService.swift new file mode 100644 index 00000000..4d540df9 --- /dev/null +++ b/Tusker/API/FetchStatusService.swift @@ -0,0 +1,47 @@ +// +// FetchStatusService.swift +// Tusker +// +// Created by Shadowfacts on 1/17/23. +// Copyright © 2023 Shadowfacts. All rights reserved. +// + +import Foundation +import Pachyderm + +@MainActor +class FetchStatusService { + let statusID: String + let mastodonController: MastodonController + + init(statusID: String, mastodonController: MastodonController) { + self.statusID = statusID + self.mastodonController = mastodonController + } + + func run() async -> Result { + let response = await mastodonController.runResponse(Client.getStatus(id: statusID)) + switch response { + case .success(let status, _): + return .loaded(status) + case .failure(let error): + switch error.type { + case .unexpectedStatus(404), .mastodonError(404, _): + self.handleStatusNotFound() + return .notFound + default: + return .error(error) + } + } + } + + private func handleStatusNotFound() { + // todo: remove from persistent store, send notifications + } + + enum Result { + case loaded(Status) + case notFound + case error(Client.Error) + } +} diff --git a/Tusker/API/MastodonController.swift b/Tusker/API/MastodonController.swift index cfe70161..3a1cfb69 100644 --- a/Tusker/API/MastodonController.swift +++ b/Tusker/API/MastodonController.swift @@ -97,19 +97,24 @@ class MastodonController: ObservableObject { return client.run(request, completion: completion) } - func run(_ request: Request) async throws -> (Result, Pagination?) { - let result: (Result, Pagination?) = try await withCheckedThrowingContinuation({ continuation in + func runResponse(_ request: Request) async -> Response { + let response = await withCheckedContinuation({ continuation in client.run(request) { response in - switch response { - case .failure(let error): - continuation.resume(throwing: error) - case .success(let result, let pagination): - continuation.resume(returning: (result, pagination)) - } + continuation.resume(returning: response) } }) + return response + } + + func run(_ request: Request) async throws -> (Result, Pagination?) { + let response = await runResponse(request) try Task.checkCancellation() - return result + switch response { + case .failure(let error): + throw error + case .success(let result, let pagination): + return (result, pagination) + } } /// - Returns: A tuple of client ID and client secret. diff --git a/Tusker/Scenes/AuxiliarySceneDelegate.swift b/Tusker/Scenes/AuxiliarySceneDelegate.swift index eaecac21..46c9a7af 100644 --- a/Tusker/Scenes/AuxiliarySceneDelegate.swift +++ b/Tusker/Scenes/AuxiliarySceneDelegate.swift @@ -75,7 +75,7 @@ class AuxiliarySceneDelegate: UIResponder, UIWindowSceneDelegate { case .showConversation: guard let id = UserActivityManager.getConversationStatus(from: activity) else { return nil } - return ConversationTableViewController(for: id, mastodonController: mastodonController) + return ConversationViewController(for: id, state: .unknown, mastodonController: mastodonController) case .checkNotifications: guard let mode = UserActivityManager.getNotificationsMode(from: activity) else { return nil } diff --git a/Tusker/Screens/Conversation/ConversationTableViewController.swift b/Tusker/Screens/Conversation/ConversationTableViewController.swift index bf4c8f9c..b1ea2098 100644 --- a/Tusker/Screens/Conversation/ConversationTableViewController.swift +++ b/Tusker/Screens/Conversation/ConversationTableViewController.swift @@ -22,11 +22,6 @@ class ConversationNode { class ConversationTableViewController: EnhancedTableViewController { - static let showPostsImage = UIImage(systemName: "eye.fill")! - static let hidePostsImage = UIImage(systemName: "eye.slash.fill")! - - static let bottomSeparatorTag = 101 - weak var mastodonController: MastodonController! let mainStatusID: String @@ -35,11 +30,8 @@ class ConversationTableViewController: EnhancedTableViewController { private(set) var dataSource: UITableViewDiffableDataSource! var showStatusesAutomatically = false - var visibilityBarButtonItem: UIBarButtonItem! - private var loadingState = LoadingState.unloaded - - init(for mainStatusID: String, state: CollapseState = .unknown, mastodonController: MastodonController) { + init(for mainStatusID: String, state: CollapseState, mastodonController: MastodonController) { self.mainStatusID = mainStatusID self.mainStatusState = state self.statusIDToScrollToOnLoad = mainStatusID @@ -57,8 +49,6 @@ class ConversationTableViewController: EnhancedTableViewController { override func viewDidLoad() { super.viewDidLoad() - title = NSLocalizedString("Conversation", comment: "conversation screen title") - tableView.delegate = self tableView.dataSource = self tableView.prefetchDataSource = self @@ -99,9 +89,9 @@ class ConversationTableViewController: EnhancedTableViewController { cell.setShowThreadLinks(prev: !firstInSection, next: !lastInSection) if lastInSection { - if cell.viewWithTag(ConversationTableViewController.bottomSeparatorTag) == nil { + if cell.viewWithTag(ViewTags.conversationBottomSeparator) == nil { let separator = UIView() - separator.tag = ConversationTableViewController.bottomSeparatorTag + separator.tag = ViewTags.conversationBottomSeparator separator.translatesAutoresizingMaskIntoConstraints = false separator.backgroundColor = tableView.separatorColor cell.addSubview(separator) @@ -113,7 +103,7 @@ class ConversationTableViewController: EnhancedTableViewController { ]) } } else { - cell.viewWithTag(ConversationTableViewController.bottomSeparatorTag)?.removeFromSuperview() + cell.viewWithTag(ViewTags.conversationBottomSeparator)?.removeFromSuperview() } return cell @@ -124,97 +114,24 @@ class ConversationTableViewController: EnhancedTableViewController { return cell } }) - - visibilityBarButtonItem = UIBarButtonItem(image: ConversationTableViewController.showPostsImage, style: .plain, target: self, action: #selector(toggleVisibilityButtonPressed)) - updateVisibilityBarButtonItem() - navigationItem.rightBarButtonItem = visibilityBarButtonItem - // disable transparent background when scroll to top because it looks weird when items earlier in the thread load in - // (it remains transparent slightly too long, resulting in a flash of the content under the transparent bar) - let appearance = UINavigationBarAppearance() - appearance.configureWithDefaultBackground() - navigationItem.scrollEdgeAppearance = appearance - - Task { - await loadMainStatus() - } } - private func updateVisibilityBarButtonItem() { - visibilityBarButtonItem.isSelected = showStatusesAutomatically - visibilityBarButtonItem.accessibilityLabel = showStatusesAutomatically ? "Collapse All" : "Expand All" - } - - @MainActor - private func loadMainStatus() async { - guard loadingState == .unloaded else { return } + func addMainStatus(_ status: StatusMO) { + loadViewIfNeeded() - if let mainStatus = mastodonController.persistentContainer.status(for: mainStatusID) { - await mainStatusLoaded(mainStatus) - } else { - loadingState = .loadingMain - let req = Client.getStatus(id: mainStatusID) - do { - let (status, _) = try await mastodonController.run(req) - let statusMO = mastodonController.persistentContainer.addOrUpdateOnViewContext(status: status) - await mainStatusLoaded(statusMO) - } catch { - let error = error as! Client.Error - loadingState = .unloaded - let config = ToastConfiguration(from: error, with: "Error Loading Status", in: self) { [weak self] toast in - toast.dismissToast(animated: true) - await self?.loadMainStatus() - } - showToast(configuration: config, animated: true) - return - } - } - } - - private func mainStatusLoaded(_ mainStatus: StatusMO) async { let mainStatusItem = Item.status(id: mainStatusID, state: mainStatusState) - var snapshot = NSDiffableDataSourceSnapshot() snapshot.appendSections([.statuses]) snapshot.appendItems([mainStatusItem], toSection: .statuses) - await dataSource.apply(snapshot, animatingDifferences: false) - - loadingState = .loadedMain - - await loadContext(for: mainStatus) + dataSource.apply(snapshot, animatingDifferences: false) } - @MainActor - private func loadContext(for mainStatus: StatusMO) async { - guard loadingState == .loadedMain else { return } + 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) } - loadingState = .loadingContext + await mastodonController.persistentContainer.addAll(statuses: parentStatuses + context.descendants) - // save the id here because we can't access the MO from the whatever thread the network callback happens on - let mainStatusInReplyToID = mainStatus.inReplyToID - - // todo: it would be nice to cache these contexts - let request = Status.getContext(mainStatusID) - do { - let (context, _) = try await mastodonController.run(request) - let parentIDs = self.getDirectParents(inReplyTo: mainStatusInReplyToID, from: context.ancestors) - let parentStatuses = context.ancestors.filter { parentIDs.contains($0.id) } - - await mastodonController.persistentContainer.addAll(statuses: parentStatuses + context.descendants) - self.contextLoaded(mainStatus: mainStatus, context: context, parentIDs: parentIDs) - - } catch { - let error = error as! Client.Error - self.loadingState = .loadedMain - - let config = ToastConfiguration(from: error, with: "Error Loading Content", in: self) { [weak self] (toast) in - toast.dismissToast(animated: true) - await self?.loadContext(for: mainStatus) - } - self.showToast(configuration: config, animated: true) - } - } - - private func contextLoaded(mainStatus: StatusMO, context: ConversationContext, parentIDs: [String]) { let mainStatusItem = Item.status(id: mainStatusID, state: mainStatusState) var snapshot = self.dataSource.snapshot() @@ -249,8 +166,6 @@ class ConversationTableViewController: EnhancedTableViewController { self.tableView.scrollToRow(at: indexPath, at: position, animated: false) } } - - self.loadingState = .loadedAll } private func getDirectParents(inReplyTo inReplyToID: String?, from statuses: [Status]) -> [String] { @@ -377,10 +292,8 @@ class ConversationTableViewController: EnhancedTableViewController { override func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? { return (tableView.cellForRow(at: indexPath) as? TableViewSwipeActionProvider)?.trailingSwipeActionsConfiguration() } - - @objc func toggleVisibilityButtonPressed() { - showStatusesAutomatically = !showStatusesAutomatically - + + func updateVisibleCellCollapseState() { let snapshot = dataSource.snapshot() for case let .status(id: _, state: state) in snapshot.itemIdentifiers where state.collapsible == true { state.collapsed = !showStatusesAutomatically @@ -396,10 +309,7 @@ class ConversationTableViewController: EnhancedTableViewController { // recalculate cell heights tableView.beginUpdates() tableView.endUpdates() - - updateVisibilityBarButtonItem() } - } extension ConversationTableViewController { @@ -436,21 +346,11 @@ extension ConversationTableViewController { } } -extension ConversationTableViewController { - private enum LoadingState: Equatable { - case unloaded - case loadingMain - case loadedMain - case loadingContext - case loadedAll - } -} - extension ConversationTableViewController: TuskerNavigationDelegate { var apiController: MastodonController! { mastodonController } - func conversation(mainStatusID: String, state: CollapseState) -> ConversationTableViewController { - let vc = ConversationTableViewController(for: mainStatusID, state: state, 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 diff --git a/Tusker/Screens/Conversation/ConversationViewController.swift b/Tusker/Screens/Conversation/ConversationViewController.swift new file mode 100644 index 00000000..01c05646 --- /dev/null +++ b/Tusker/Screens/Conversation/ConversationViewController.swift @@ -0,0 +1,262 @@ +// +// ConversationViewController.swift +// Tusker +// +// Created by Shadowfacts on 1/17/23. +// Copyright © 2023 Shadowfacts. All rights reserved. +// + +import UIKit +import Pachyderm + +class ConversationViewController: UIViewController { + + weak var mastodonController: MastodonController! + + let mainStatusID: String + let mainStatusState: CollapseState + var statusIDToScrollToOnLoad: String { + didSet { + if case .displaying(let vc) = state { + vc.statusIDToScrollToOnLoad = statusIDToScrollToOnLoad + } + } + } + var showStatusesAutomatically = false { + didSet { + if case .displaying(let vc) = state { + vc.showStatusesAutomatically = showStatusesAutomatically + } + } + } + + private var collapseBarButtonItem: UIBarButtonItem! + + private var state: State = .unloaded { + didSet { + switch oldValue { + case .loading(let indicator): + indicator.removeFromSuperview() + case .displaying(let vc): + vc.removeViewAndController() + default: + break + } + + switch state { + case .unloaded: + break + case .loading(let indicator): + indicator.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(indicator) + NSLayoutConstraint.activate([ + indicator.centerXAnchor.constraint(equalTo: view.safeAreaLayoutGuide.centerXAnchor), + indicator.centerYAnchor.constraint(equalTo: view.safeAreaLayoutGuide.centerYAnchor), + ]) + case .displaying(let vc): + embedChild(vc) + case .notFound: + showMainStatusNotFound() + } + + updateVisibilityBarButtonItem() + } + } + + init(for mainStatusID: String, state mainStatusState: CollapseState, mastodonController: MastodonController) { + self.mainStatusID = mainStatusID + self.mainStatusState = mainStatusState + 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 viewDidLoad() { + super.viewDidLoad() + + title = NSLocalizedString("Conversation", comment: "conversation screen title") + + view.backgroundColor = .secondarySystemBackground + + collapseBarButtonItem = UIBarButtonItem(image: UIImage(systemName: "eye.fill")!, style: .plain, target: self, action: #selector(toggleCollapseButtonPressed)) + updateVisibilityBarButtonItem() + navigationItem.rightBarButtonItem = collapseBarButtonItem + // disable transparent background when scroll to top because it looks weird when items earlier in the thread load in + // (it remains transparent slightly too long, resulting in a flash of the content under the transparent bar) + let appearance = UINavigationBarAppearance() + appearance.configureWithDefaultBackground() + navigationItem.scrollEdgeAppearance = appearance + } + + private func updateVisibilityBarButtonItem() { + switch state { + case .loading(_), .displaying(_): + collapseBarButtonItem.isEnabled = true + default: + collapseBarButtonItem.isEnabled = false + } + + collapseBarButtonItem.isSelected = showStatusesAutomatically + collapseBarButtonItem.accessibilityLabel = showStatusesAutomatically ? "Collapse All" : "Expand All" + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + Task { + await loadMainStatus() + } + } + + // MARK: Loading + + private func loadMainStatus() async { + @MainActor + func doLoadMainStatus() async -> StatusMO? { + switch await FetchStatusService(statusID: mainStatusID, mastodonController: mastodonController).run() { + case .loaded(let status): + return mastodonController.persistentContainer.addOrUpdateOnViewContext(status: status) + case .notFound: + state = .notFound + showMainStatusNotFound() + return nil + case .error(let error): + self.showMainStatusError(error) + return nil + } + } + + + if let cached = mastodonController.persistentContainer.status(for: mainStatusID) { + // if we have a cached copy, display it immediately but still try to refresh it + Task { + await doLoadMainStatus() + } + await mainStatusLoaded(cached) + } else { + // otherwise, show a loading indicator while loading the main status + let indicator = UIActivityIndicatorView(style: .medium) + indicator.startAnimating() + state = .loading(indicator) + + if let status = await doLoadMainStatus() { + await mainStatusLoaded(status) + } + } + } + + @MainActor + private func mainStatusLoaded(_ mainStatus: StatusMO) async { + let vc = ConversationTableViewController(for: mainStatusID, state: mainStatusState, mastodonController: mastodonController) + vc.statusIDToScrollToOnLoad = statusIDToScrollToOnLoad + vc.showStatusesAutomatically = showStatusesAutomatically + vc.addMainStatus(mainStatus) + state = .displaying(vc) + + await loadContext(for: mainStatus) + } + + @MainActor + private func loadContext(for mainStatus: StatusMO) async { + guard case .displaying(_) = state else { + return + } + + let request = Status.getContext(mainStatus.id) + do { + let (context, _) = try await mastodonController.run(request) + guard case .displaying(let vc) = state else { + return + } + + await vc.addContext(context, for: mainStatus) + } catch { + guard case .displaying(_) = state else { + return + } + let error = error as! Client.Error + + let config = ToastConfiguration(from: error, with: "Error Loading Context", in: self) { [weak self] toast in + toast.dismissToast(animated: true) + await self?.loadContext(for: mainStatus) + } + self.showToast(configuration: config, animated: true) + } + } + + private func showMainStatusNotFound() { + let emoji = UILabel() + emoji.font = .systemFont(ofSize: 64) + emoji.text = "🤷" + + let title = UILabel() + title.textColor = .secondaryLabel + title.font = .preferredFont(forTextStyle: .title1).withTraits(.traitBold)! + title.adjustsFontForContentSizeCategory = true + title.text = "Not Found" + + let subtitle = UILabel() + subtitle.textColor = .secondaryLabel + subtitle.font = .preferredFont(forTextStyle: .body) + subtitle.adjustsFontForContentSizeCategory = true + subtitle.text = "The post you are looking for may have been deleted, or may not be visible to you." + subtitle.numberOfLines = 0 + subtitle.textAlignment = .center + + let stack = UIStackView(arrangedSubviews: [ + emoji, + title, + subtitle, + ]) + 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([ + 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), + ]) + } + + private func showMainStatusError(_ error: Client.Error) { + let config = ToastConfiguration(from: error, with: "Error Loading Status", in: self) { [weak self] toast in + toast.dismissToast(animated: true) + await self?.loadMainStatus() + } + self.showToast(configuration: config, animated: true) + } + + // MARK: Interaction + + @objc func toggleCollapseButtonPressed() { + guard case .displaying(let vc) = state else { + return + } + showStatusesAutomatically = !showStatusesAutomatically + vc.updateVisibleCellCollapseState() + updateVisibilityBarButtonItem() + } + +} + +extension ConversationViewController { + enum State { + case unloaded + case loading(UIActivityIndicatorView) + case displaying(ConversationTableViewController) + case notFound + } +} + +extension ConversationViewController: ToastableViewController { +} diff --git a/Tusker/TuskerNavigationDelegate.swift b/Tusker/TuskerNavigationDelegate.swift index f9079523..b4d4d62c 100644 --- a/Tusker/TuskerNavigationDelegate.swift +++ b/Tusker/TuskerNavigationDelegate.swift @@ -13,7 +13,7 @@ import Pachyderm protocol TuskerNavigationDelegate: UIViewController, ToastableViewController { var apiController: MastodonController! { get } - func conversation(mainStatusID: String, state: CollapseState) -> ConversationTableViewController + func conversation(mainStatusID: String, state: CollapseState) -> ConversationViewController } extension TuskerNavigationDelegate { @@ -78,8 +78,8 @@ extension TuskerNavigationDelegate { } } - func conversation(mainStatusID: String, state: CollapseState) -> ConversationTableViewController { - return ConversationTableViewController(for: mainStatusID, state: state, mastodonController: apiController) + func conversation(mainStatusID: String, state: CollapseState) -> ConversationViewController { + return ConversationViewController(for: mainStatusID, state: state, mastodonController: apiController) } func selected(status statusID: String) { diff --git a/Tusker/ViewTags.swift b/Tusker/ViewTags.swift index 294ecccc..01c5b8f2 100644 --- a/Tusker/ViewTags.swift +++ b/Tusker/ViewTags.swift @@ -16,4 +16,5 @@ struct ViewTags { static let navEmptyTitleView = 42003 static let splitNavCloseSecondaryButton = 42004 static let customAlertSeparator = 42005 + static let conversationBottomSeparator = 42006 } diff --git a/Tusker/Views/Status/TimelineStatusCollectionViewCell.swift b/Tusker/Views/Status/TimelineStatusCollectionViewCell.swift index e299811e..8fc090fb 100644 --- a/Tusker/Views/Status/TimelineStatusCollectionViewCell.swift +++ b/Tusker/Views/Status/TimelineStatusCollectionViewCell.swift @@ -759,7 +759,7 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell, StatusCollecti return nil } return UIContextMenuConfiguration { - ConversationTableViewController(for: self.statusID, state: self.statusState.copy(), mastodonController: self.mastodonController) + ConversationViewController(for: self.statusID, state: self.statusState.copy(), mastodonController: self.mastodonController) } actionProvider: { _ in UIMenu(children: self.delegate!.actionsForStatus(status, source: .view(self))) } diff --git a/Tusker/Views/Status/TimelineStatusTableViewCell.swift b/Tusker/Views/Status/TimelineStatusTableViewCell.swift index e698d6f7..971aeac9 100644 --- a/Tusker/Views/Status/TimelineStatusTableViewCell.swift +++ b/Tusker/Views/Status/TimelineStatusTableViewCell.swift @@ -433,7 +433,7 @@ extension TimelineStatusTableViewCell: MenuPreviewProvider { return nil } return ( - content: { ConversationTableViewController(for: self.statusID, state: self.statusState.copy(), mastodonController: mastodonController) }, + content: { ConversationViewController(for: self.statusID, state: self.statusState.copy(), mastodonController: mastodonController) }, actions: { self.delegate?.actionsForStatus(status, source: .view(self)) ?? [] } ) }