forked from shadowfacts/Tusker
Show better message when opening conv for deleted status
Also split conversation loading out into separate view controller
This commit is contained in:
parent
39bff06897
commit
6f006adbc1
|
@ -150,6 +150,8 @@
|
||||||
D65B4B58297203A700DABDFB /* ReportSelectRulesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D65B4B57297203A700DABDFB /* ReportSelectRulesView.swift */; };
|
D65B4B58297203A700DABDFB /* ReportSelectRulesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D65B4B57297203A700DABDFB /* ReportSelectRulesView.swift */; };
|
||||||
D65B4B5A29720AB000DABDFB /* ReportStatusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D65B4B5929720AB000DABDFB /* ReportStatusView.swift */; };
|
D65B4B5A29720AB000DABDFB /* ReportStatusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D65B4B5929720AB000DABDFB /* ReportStatusView.swift */; };
|
||||||
D65B4B5E2973040D00DABDFB /* ReportAddStatusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D65B4B5D2973040D00DABDFB /* ReportAddStatusView.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 */; };
|
D65C6BF525478A9C00A6E89C /* BackgroundableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D65C6BF425478A9C00A6E89C /* BackgroundableViewController.swift */; };
|
||||||
D65F613423AEAB6600F3CFD3 /* OnboardingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D65F613323AEAB6600F3CFD3 /* OnboardingTests.swift */; };
|
D65F613423AEAB6600F3CFD3 /* OnboardingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D65F613323AEAB6600F3CFD3 /* OnboardingTests.swift */; };
|
||||||
D6620ACE2511A0ED00312CA0 /* StatusStateResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6620ACD2511A0ED00312CA0 /* StatusStateResolver.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 = "<group>"; };
|
D65B4B57297203A700DABDFB /* ReportSelectRulesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportSelectRulesView.swift; sourceTree = "<group>"; };
|
||||||
D65B4B5929720AB000DABDFB /* ReportStatusView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportStatusView.swift; sourceTree = "<group>"; };
|
D65B4B5929720AB000DABDFB /* ReportStatusView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportStatusView.swift; sourceTree = "<group>"; };
|
||||||
D65B4B5D2973040D00DABDFB /* ReportAddStatusView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportAddStatusView.swift; sourceTree = "<group>"; };
|
D65B4B5D2973040D00DABDFB /* ReportAddStatusView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportAddStatusView.swift; sourceTree = "<group>"; };
|
||||||
|
D65B4B6129771A3F00DABDFB /* FetchStatusService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FetchStatusService.swift; sourceTree = "<group>"; };
|
||||||
|
D65B4B6329771EFF00DABDFB /* ConversationViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationViewController.swift; sourceTree = "<group>"; };
|
||||||
D65C6BF425478A9C00A6E89C /* BackgroundableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundableViewController.swift; sourceTree = "<group>"; };
|
D65C6BF425478A9C00A6E89C /* BackgroundableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundableViewController.swift; sourceTree = "<group>"; };
|
||||||
D65F612D23AE990C00F3CFD3 /* Embassy.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = Embassy.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
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; };
|
D65F613023AE99E000F3CFD3 /* Ambassador.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = Ambassador.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
|
@ -1033,6 +1037,7 @@
|
||||||
D641C785213DD83B004B4513 /* Conversation */ = {
|
D641C785213DD83B004B4513 /* Conversation */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
D65B4B6329771EFF00DABDFB /* ConversationViewController.swift */,
|
||||||
D667E5F42135BCD50057A976 /* ConversationTableViewController.swift */,
|
D667E5F42135BCD50057A976 /* ConversationTableViewController.swift */,
|
||||||
D6C82B5425C5F3F20017F1E6 /* ExpandThreadTableViewCell.swift */,
|
D6C82B5425C5F3F20017F1E6 /* ExpandThreadTableViewCell.swift */,
|
||||||
D6C82B5525C5F3F20017F1E6 /* ExpandThreadTableViewCell.xib */,
|
D6C82B5525C5F3F20017F1E6 /* ExpandThreadTableViewCell.xib */,
|
||||||
|
@ -1606,6 +1611,7 @@
|
||||||
D61F75B0293BD85300C0B37F /* CreateFilterService.swift */,
|
D61F75B0293BD85300C0B37F /* CreateFilterService.swift */,
|
||||||
D61F75B2293BD89C00C0B37F /* UpdateFilterService.swift */,
|
D61F75B2293BD89C00C0B37F /* UpdateFilterService.swift */,
|
||||||
D61F75B4293BD97400C0B37F /* DeleteFilterService.swift */,
|
D61F75B4293BD97400C0B37F /* DeleteFilterService.swift */,
|
||||||
|
D65B4B6129771A3F00DABDFB /* FetchStatusService.swift */,
|
||||||
);
|
);
|
||||||
path = API;
|
path = API;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
|
@ -2008,6 +2014,7 @@
|
||||||
D63CC7102911F1E4000E19DE /* UIScrollView+Top.swift in Sources */,
|
D63CC7102911F1E4000E19DE /* UIScrollView+Top.swift in Sources */,
|
||||||
D6F6A557291F4F1600F496A8 /* MuteAccountView.swift in Sources */,
|
D6F6A557291F4F1600F496A8 /* MuteAccountView.swift in Sources */,
|
||||||
D6945C3A23AC75E2005C403C /* FindInstanceViewController.swift in Sources */,
|
D6945C3A23AC75E2005C403C /* FindInstanceViewController.swift in Sources */,
|
||||||
|
D65B4B6429771EFF00DABDFB /* ConversationViewController.swift in Sources */,
|
||||||
D68E6F59253C9969001A1B4C /* MultiSourceEmojiLabel.swift in Sources */,
|
D68E6F59253C9969001A1B4C /* MultiSourceEmojiLabel.swift in Sources */,
|
||||||
D63F9C6E241D2D85004C03CF /* CompositionAttachment.swift in Sources */,
|
D63F9C6E241D2D85004C03CF /* CompositionAttachment.swift in Sources */,
|
||||||
D6EBF01523C55C0900AE061B /* UIApplication+Scenes.swift in Sources */,
|
D6EBF01523C55C0900AE061B /* UIApplication+Scenes.swift in Sources */,
|
||||||
|
@ -2027,6 +2034,7 @@
|
||||||
D659F36229541065002D944A /* TTTView.swift in Sources */,
|
D659F36229541065002D944A /* TTTView.swift in Sources */,
|
||||||
D6C143DA253510F4007DC240 /* ComposeEmojiTextField.swift in Sources */,
|
D6C143DA253510F4007DC240 /* ComposeEmojiTextField.swift in Sources */,
|
||||||
D6DD2A3F273C1F4900386A6C /* ComposeAttachmentImage.swift in Sources */,
|
D6DD2A3F273C1F4900386A6C /* ComposeAttachmentImage.swift in Sources */,
|
||||||
|
D65B4B6229771A3F00DABDFB /* FetchStatusService.swift in Sources */,
|
||||||
D6DD2A45273D6C5700386A6C /* GIFImageView.swift in Sources */,
|
D6DD2A45273D6C5700386A6C /* GIFImageView.swift in Sources */,
|
||||||
D61F759029353B4300C0B37F /* FileManager+Size.swift in Sources */,
|
D61F759029353B4300C0B37F /* FileManager+Size.swift in Sources */,
|
||||||
0427033822B30F5F000D31B6 /* BehaviorPrefsView.swift in Sources */,
|
0427033822B30F5F000D31B6 /* BehaviorPrefsView.swift in Sources */,
|
||||||
|
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
|
@ -97,19 +97,24 @@ class MastodonController: ObservableObject {
|
||||||
return client.run(request, completion: completion)
|
return client.run(request, completion: completion)
|
||||||
}
|
}
|
||||||
|
|
||||||
func run<Result>(_ request: Request<Result>) async throws -> (Result, Pagination?) {
|
func runResponse<Result>(_ request: Request<Result>) async -> Response<Result> {
|
||||||
let result: (Result, Pagination?) = try await withCheckedThrowingContinuation({ continuation in
|
let response = await withCheckedContinuation({ continuation in
|
||||||
client.run(request) { response in
|
client.run(request) { response in
|
||||||
switch response {
|
continuation.resume(returning: response)
|
||||||
case .failure(let error):
|
|
||||||
continuation.resume(throwing: error)
|
|
||||||
case .success(let result, let pagination):
|
|
||||||
continuation.resume(returning: (result, pagination))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
|
func run<Result>(_ request: Request<Result>) async throws -> (Result, Pagination?) {
|
||||||
|
let response = await runResponse(request)
|
||||||
try Task.checkCancellation()
|
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.
|
/// - Returns: A tuple of client ID and client secret.
|
||||||
|
|
|
@ -75,7 +75,7 @@ class AuxiliarySceneDelegate: UIResponder, UIWindowSceneDelegate {
|
||||||
|
|
||||||
case .showConversation:
|
case .showConversation:
|
||||||
guard let id = UserActivityManager.getConversationStatus(from: activity) else { return nil }
|
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:
|
case .checkNotifications:
|
||||||
guard let mode = UserActivityManager.getNotificationsMode(from: activity) else { return nil }
|
guard let mode = UserActivityManager.getNotificationsMode(from: activity) else { return nil }
|
||||||
|
|
|
@ -22,11 +22,6 @@ class ConversationNode {
|
||||||
|
|
||||||
class ConversationTableViewController: EnhancedTableViewController {
|
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!
|
weak var mastodonController: MastodonController!
|
||||||
|
|
||||||
let mainStatusID: String
|
let mainStatusID: String
|
||||||
|
@ -35,11 +30,8 @@ class ConversationTableViewController: EnhancedTableViewController {
|
||||||
private(set) var dataSource: UITableViewDiffableDataSource<Section, Item>!
|
private(set) var dataSource: UITableViewDiffableDataSource<Section, Item>!
|
||||||
|
|
||||||
var showStatusesAutomatically = false
|
var showStatusesAutomatically = false
|
||||||
var visibilityBarButtonItem: UIBarButtonItem!
|
|
||||||
|
|
||||||
private var loadingState = LoadingState.unloaded
|
init(for mainStatusID: String, state: CollapseState, mastodonController: MastodonController) {
|
||||||
|
|
||||||
init(for mainStatusID: String, state: CollapseState = .unknown, mastodonController: MastodonController) {
|
|
||||||
self.mainStatusID = mainStatusID
|
self.mainStatusID = mainStatusID
|
||||||
self.mainStatusState = state
|
self.mainStatusState = state
|
||||||
self.statusIDToScrollToOnLoad = mainStatusID
|
self.statusIDToScrollToOnLoad = mainStatusID
|
||||||
|
@ -57,8 +49,6 @@ class ConversationTableViewController: EnhancedTableViewController {
|
||||||
override func viewDidLoad() {
|
override func viewDidLoad() {
|
||||||
super.viewDidLoad()
|
super.viewDidLoad()
|
||||||
|
|
||||||
title = NSLocalizedString("Conversation", comment: "conversation screen title")
|
|
||||||
|
|
||||||
tableView.delegate = self
|
tableView.delegate = self
|
||||||
tableView.dataSource = self
|
tableView.dataSource = self
|
||||||
tableView.prefetchDataSource = self
|
tableView.prefetchDataSource = self
|
||||||
|
@ -99,9 +89,9 @@ class ConversationTableViewController: EnhancedTableViewController {
|
||||||
|
|
||||||
cell.setShowThreadLinks(prev: !firstInSection, next: !lastInSection)
|
cell.setShowThreadLinks(prev: !firstInSection, next: !lastInSection)
|
||||||
if lastInSection {
|
if lastInSection {
|
||||||
if cell.viewWithTag(ConversationTableViewController.bottomSeparatorTag) == nil {
|
if cell.viewWithTag(ViewTags.conversationBottomSeparator) == nil {
|
||||||
let separator = UIView()
|
let separator = UIView()
|
||||||
separator.tag = ConversationTableViewController.bottomSeparatorTag
|
separator.tag = ViewTags.conversationBottomSeparator
|
||||||
separator.translatesAutoresizingMaskIntoConstraints = false
|
separator.translatesAutoresizingMaskIntoConstraints = false
|
||||||
separator.backgroundColor = tableView.separatorColor
|
separator.backgroundColor = tableView.separatorColor
|
||||||
cell.addSubview(separator)
|
cell.addSubview(separator)
|
||||||
|
@ -113,7 +103,7 @@ class ConversationTableViewController: EnhancedTableViewController {
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
cell.viewWithTag(ConversationTableViewController.bottomSeparatorTag)?.removeFromSuperview()
|
cell.viewWithTag(ViewTags.conversationBottomSeparator)?.removeFromSuperview()
|
||||||
}
|
}
|
||||||
|
|
||||||
return cell
|
return cell
|
||||||
|
@ -124,97 +114,24 @@ class ConversationTableViewController: EnhancedTableViewController {
|
||||||
return cell
|
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() {
|
func addMainStatus(_ status: StatusMO) {
|
||||||
visibilityBarButtonItem.isSelected = showStatusesAutomatically
|
loadViewIfNeeded()
|
||||||
visibilityBarButtonItem.accessibilityLabel = showStatusesAutomatically ? "Collapse All" : "Expand All"
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
private func loadMainStatus() async {
|
|
||||||
guard loadingState == .unloaded else { return }
|
|
||||||
|
|
||||||
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)
|
let mainStatusItem = Item.status(id: mainStatusID, state: mainStatusState)
|
||||||
|
|
||||||
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
|
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
|
||||||
snapshot.appendSections([.statuses])
|
snapshot.appendSections([.statuses])
|
||||||
snapshot.appendItems([mainStatusItem], toSection: .statuses)
|
snapshot.appendItems([mainStatusItem], toSection: .statuses)
|
||||||
await dataSource.apply(snapshot, animatingDifferences: false)
|
dataSource.apply(snapshot, animatingDifferences: false)
|
||||||
|
|
||||||
loadingState = .loadedMain
|
|
||||||
|
|
||||||
await loadContext(for: mainStatus)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
func addContext(_ context: ConversationContext, for mainStatus: StatusMO) async {
|
||||||
private func loadContext(for mainStatus: StatusMO) async {
|
let parentIDs = self.getDirectParents(inReplyTo: mainStatus.inReplyToID, from: context.ancestors)
|
||||||
guard loadingState == .loadedMain else { return }
|
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)
|
let mainStatusItem = Item.status(id: mainStatusID, state: mainStatusState)
|
||||||
|
|
||||||
var snapshot = self.dataSource.snapshot()
|
var snapshot = self.dataSource.snapshot()
|
||||||
|
@ -249,8 +166,6 @@ class ConversationTableViewController: EnhancedTableViewController {
|
||||||
self.tableView.scrollToRow(at: indexPath, at: position, animated: false)
|
self.tableView.scrollToRow(at: indexPath, at: position, animated: false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
self.loadingState = .loadedAll
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func getDirectParents(inReplyTo inReplyToID: String?, from statuses: [Status]) -> [String] {
|
private func getDirectParents(inReplyTo inReplyToID: String?, from statuses: [Status]) -> [String] {
|
||||||
|
@ -378,9 +293,7 @@ class ConversationTableViewController: EnhancedTableViewController {
|
||||||
return (tableView.cellForRow(at: indexPath) as? TableViewSwipeActionProvider)?.trailingSwipeActionsConfiguration()
|
return (tableView.cellForRow(at: indexPath) as? TableViewSwipeActionProvider)?.trailingSwipeActionsConfiguration()
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func toggleVisibilityButtonPressed() {
|
func updateVisibleCellCollapseState() {
|
||||||
showStatusesAutomatically = !showStatusesAutomatically
|
|
||||||
|
|
||||||
let snapshot = dataSource.snapshot()
|
let snapshot = dataSource.snapshot()
|
||||||
for case let .status(id: _, state: state) in snapshot.itemIdentifiers where state.collapsible == true {
|
for case let .status(id: _, state: state) in snapshot.itemIdentifiers where state.collapsible == true {
|
||||||
state.collapsed = !showStatusesAutomatically
|
state.collapsed = !showStatusesAutomatically
|
||||||
|
@ -396,10 +309,7 @@ class ConversationTableViewController: EnhancedTableViewController {
|
||||||
// recalculate cell heights
|
// recalculate cell heights
|
||||||
tableView.beginUpdates()
|
tableView.beginUpdates()
|
||||||
tableView.endUpdates()
|
tableView.endUpdates()
|
||||||
|
|
||||||
updateVisibilityBarButtonItem()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
extension ConversationTableViewController {
|
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 {
|
extension ConversationTableViewController: TuskerNavigationDelegate {
|
||||||
var apiController: MastodonController! { mastodonController }
|
var apiController: MastodonController! { mastodonController }
|
||||||
|
|
||||||
func conversation(mainStatusID: String, state: CollapseState) -> ConversationTableViewController {
|
func conversation(mainStatusID: String, state: CollapseState) -> ConversationViewController {
|
||||||
let vc = ConversationTableViewController(for: mainStatusID, state: state, mastodonController: mastodonController)
|
let vc = ConversationViewController(for: mainStatusID, state: state, mastodonController: mastodonController)
|
||||||
// transfer show statuses automatically state when showing new conversation
|
// transfer show statuses automatically state when showing new conversation
|
||||||
vc.showStatusesAutomatically = self.showStatusesAutomatically
|
vc.showStatusesAutomatically = self.showStatusesAutomatically
|
||||||
return vc
|
return vc
|
||||||
|
|
|
@ -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 {
|
||||||
|
}
|
|
@ -13,7 +13,7 @@ import Pachyderm
|
||||||
protocol TuskerNavigationDelegate: UIViewController, ToastableViewController {
|
protocol TuskerNavigationDelegate: UIViewController, ToastableViewController {
|
||||||
var apiController: MastodonController! { get }
|
var apiController: MastodonController! { get }
|
||||||
|
|
||||||
func conversation(mainStatusID: String, state: CollapseState) -> ConversationTableViewController
|
func conversation(mainStatusID: String, state: CollapseState) -> ConversationViewController
|
||||||
}
|
}
|
||||||
|
|
||||||
extension TuskerNavigationDelegate {
|
extension TuskerNavigationDelegate {
|
||||||
|
@ -78,8 +78,8 @@ extension TuskerNavigationDelegate {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func conversation(mainStatusID: String, state: CollapseState) -> ConversationTableViewController {
|
func conversation(mainStatusID: String, state: CollapseState) -> ConversationViewController {
|
||||||
return ConversationTableViewController(for: mainStatusID, state: state, mastodonController: apiController)
|
return ConversationViewController(for: mainStatusID, state: state, mastodonController: apiController)
|
||||||
}
|
}
|
||||||
|
|
||||||
func selected(status statusID: String) {
|
func selected(status statusID: String) {
|
||||||
|
|
|
@ -16,4 +16,5 @@ struct ViewTags {
|
||||||
static let navEmptyTitleView = 42003
|
static let navEmptyTitleView = 42003
|
||||||
static let splitNavCloseSecondaryButton = 42004
|
static let splitNavCloseSecondaryButton = 42004
|
||||||
static let customAlertSeparator = 42005
|
static let customAlertSeparator = 42005
|
||||||
|
static let conversationBottomSeparator = 42006
|
||||||
}
|
}
|
||||||
|
|
|
@ -759,7 +759,7 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell, StatusCollecti
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return UIContextMenuConfiguration {
|
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
|
} actionProvider: { _ in
|
||||||
UIMenu(children: self.delegate!.actionsForStatus(status, source: .view(self)))
|
UIMenu(children: self.delegate!.actionsForStatus(status, source: .view(self)))
|
||||||
}
|
}
|
||||||
|
|
|
@ -433,7 +433,7 @@ extension TimelineStatusTableViewCell: MenuPreviewProvider {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return (
|
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)) ?? [] }
|
actions: { self.delegate?.actionsForStatus(status, source: .view(self)) ?? [] }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue