From db7c183d064e38fa8e00ad4dcce0019ac6ad5459 Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Thu, 11 May 2023 14:57:47 -0400 Subject: [PATCH] Add status edit history view --- .../Sources/Pachyderm/Model/Mention.swift | 7 + .../Sources/Pachyderm/Model/Status.swift | 4 + .../Sources/Pachyderm/Model/StatusEdit.swift | 38 ++++ Tusker.xcodeproj/project.pbxproj | 24 ++ Tusker/CoreData/AccountMO.swift | 6 + .../StatusEditCollectionViewCell.swift | 165 ++++++++++++++ .../StatusEditContentTextView.swift | 22 ++ .../StatusEditHistoryViewController.swift | 208 ++++++++++++++++++ .../StatusEditPollView.swift | 47 ++++ .../AttachmentsContainerView.swift | 8 +- Tusker/Views/BaseEmojiLabel.swift | 7 +- Tusker/Views/ContentTextView.swift | 4 +- Tusker/Views/EmojiLabel.swift | 6 +- Tusker/Views/MultiSourceEmojiLabel.swift | 6 +- .../Views/Poll/PollOptionCheckboxView.swift | 4 + Tusker/Views/Poll/StatusPollView.swift | 11 +- ...ersationMainStatusCollectionViewCell.swift | 9 +- .../Status/StatusCollectionViewCell.swift | 10 +- .../Views/Status/StatusContentContainer.swift | 6 +- .../TimelineStatusCollectionViewCell.swift | 2 +- 20 files changed, 566 insertions(+), 28 deletions(-) create mode 100644 Packages/Pachyderm/Sources/Pachyderm/Model/StatusEdit.swift create mode 100644 Tusker/Screens/Status Edit History/StatusEditCollectionViewCell.swift create mode 100644 Tusker/Screens/Status Edit History/StatusEditContentTextView.swift create mode 100644 Tusker/Screens/Status Edit History/StatusEditHistoryViewController.swift create mode 100644 Tusker/Screens/Status Edit History/StatusEditPollView.swift diff --git a/Packages/Pachyderm/Sources/Pachyderm/Model/Mention.swift b/Packages/Pachyderm/Sources/Pachyderm/Model/Mention.swift index 61425757..1d353c83 100644 --- a/Packages/Pachyderm/Sources/Pachyderm/Model/Mention.swift +++ b/Packages/Pachyderm/Sources/Pachyderm/Model/Mention.swift @@ -24,6 +24,13 @@ public struct Mention: Codable, Sendable { self.url = try container.decode(WebURL.self, forKey: .url) } + public init(url: WebURL, username: String, acct: String, id: String) { + self.url = url + self.username = username + self.acct = acct + self.id = id + } + private enum CodingKeys: String, CodingKey { case url case username diff --git a/Packages/Pachyderm/Sources/Pachyderm/Model/Status.swift b/Packages/Pachyderm/Sources/Pachyderm/Model/Status.swift index 9a8db7c4..8f07e2b0 100644 --- a/Packages/Pachyderm/Sources/Pachyderm/Model/Status.swift +++ b/Packages/Pachyderm/Sources/Pachyderm/Model/Status.swift @@ -169,6 +169,10 @@ public final class Status: StatusProtocol, Decodable, Sendable { return Request(method: .get, path: "/api/v1/statuses/\(statusID)/source") } + public static func history(_ statusID: String) -> Request<[StatusEdit]> { + return Request(method: .get, path: "/api/v1/statuses/\(statusID)/history") + } + private enum CodingKeys: String, CodingKey { case id case uri diff --git a/Packages/Pachyderm/Sources/Pachyderm/Model/StatusEdit.swift b/Packages/Pachyderm/Sources/Pachyderm/Model/StatusEdit.swift new file mode 100644 index 00000000..53aec8ad --- /dev/null +++ b/Packages/Pachyderm/Sources/Pachyderm/Model/StatusEdit.swift @@ -0,0 +1,38 @@ +// +// StatusEdit.swift +// Pachyderm +// +// Created by Shadowfacts on 5/11/23. +// + +import Foundation + +public struct StatusEdit: Decodable { + public let content: String + public let spoilerText: String + public let sensitive: Bool + public let createdAt: Date + public let account: Account + public let poll: Poll? + public let attachments: [Attachment] + public let emojis: [Emoji] + + enum CodingKeys: String, CodingKey { + case content + case spoilerText = "spoiler_text" + case sensitive + case createdAt = "created_at" + case account = "account" + case poll + case attachments = "media_attachments" + case emojis + } + + public struct Poll: Decodable { + public let options: [Option] + + public struct Option: Decodable { + public let title: String + } + } +} diff --git a/Tusker.xcodeproj/project.pbxproj b/Tusker.xcodeproj/project.pbxproj index 92f2f0d4..9ec5f866 100644 --- a/Tusker.xcodeproj/project.pbxproj +++ b/Tusker.xcodeproj/project.pbxproj @@ -297,6 +297,10 @@ D6D706A029466649000827ED /* ScrollingSegmentedControl.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D7069F29466649000827ED /* ScrollingSegmentedControl.swift */; }; D6D706A72948D4D0000827ED /* TimlineState.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D706A62948D4D0000827ED /* TimlineState.swift */; }; D6D79F262A0C8D2700AB2315 /* FetchStatusSourceService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D79F252A0C8D2700AB2315 /* FetchStatusSourceService.swift */; }; + D6D79F292A0D596B00AB2315 /* StatusEditHistoryViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D79F282A0D596B00AB2315 /* StatusEditHistoryViewController.swift */; }; + D6D79F2B2A0D5D5C00AB2315 /* StatusEditCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D79F2A2A0D5D5C00AB2315 /* StatusEditCollectionViewCell.swift */; }; + D6D79F2D2A0D61B400AB2315 /* StatusEditContentTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D79F2C2A0D61B400AB2315 /* StatusEditContentTextView.swift */; }; + D6D79F2F2A0D6A7F00AB2315 /* StatusEditPollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D79F2E2A0D6A7F00AB2315 /* StatusEditPollView.swift */; }; D6D94955298963A900C59229 /* Colors.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D94954298963A900C59229 /* Colors.swift */; }; D6D9498D298CBB4000C59229 /* TrendingStatusCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D9498C298CBB4000C59229 /* TrendingStatusCollectionViewCell.swift */; }; D6D9498F298EB79400C59229 /* CopyableLable.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D9498E298EB79400C59229 /* CopyableLable.swift */; }; @@ -692,6 +696,10 @@ D6D706A62948D4D0000827ED /* TimlineState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimlineState.swift; sourceTree = ""; }; D6D706A829498C82000827ED /* Tusker.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Tusker.xcconfig; sourceTree = ""; }; D6D79F252A0C8D2700AB2315 /* FetchStatusSourceService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FetchStatusSourceService.swift; sourceTree = ""; }; + D6D79F282A0D596B00AB2315 /* StatusEditHistoryViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusEditHistoryViewController.swift; sourceTree = ""; }; + D6D79F2A2A0D5D5C00AB2315 /* StatusEditCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusEditCollectionViewCell.swift; sourceTree = ""; }; + D6D79F2C2A0D61B400AB2315 /* StatusEditContentTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusEditContentTextView.swift; sourceTree = ""; }; + D6D79F2E2A0D6A7F00AB2315 /* StatusEditPollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusEditPollView.swift; sourceTree = ""; }; D6D94954298963A900C59229 /* Colors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Colors.swift; sourceTree = ""; }; D6D9498C298CBB4000C59229 /* TrendingStatusCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrendingStatusCollectionViewCell.swift; sourceTree = ""; }; D6D9498E298EB79400C59229 /* CopyableLable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CopyableLable.swift; sourceTree = ""; }; @@ -979,6 +987,7 @@ D65B4B522971F6E300DABDFB /* Report */, D6BC9DD8232D8BCA002CA326 /* Search */, D6A3BC8C2321FF9B00FD64D5 /* Status Action Account List */, + D6D79F272A0D595D00AB2315 /* Status Edit History */, D641C781213DD7DD004B4513 /* Timeline */, D6C693FA2162FE5D007D6A6D /* Utilities */, ); @@ -1532,6 +1541,17 @@ path = Gestures; sourceTree = ""; }; + D6D79F272A0D595D00AB2315 /* Status Edit History */ = { + isa = PBXGroup; + children = ( + D6D79F282A0D596B00AB2315 /* StatusEditHistoryViewController.swift */, + D6D79F2A2A0D5D5C00AB2315 /* StatusEditCollectionViewCell.swift */, + D6D79F2C2A0D61B400AB2315 /* StatusEditContentTextView.swift */, + D6D79F2E2A0D6A7F00AB2315 /* StatusEditPollView.swift */, + ); + path = "Status Edit History"; + sourceTree = ""; + }; D6DEA0DB268400AF00FE896A /* Confirm Load More Cell */ = { isa = PBXGroup; children = ( @@ -1952,6 +1972,7 @@ D62D2426217ABF63005076CC /* UserActivityType.swift in Sources */, D6AC956723C4347E008C9946 /* MainSceneDelegate.swift in Sources */, D6311C5025B3765B00B27539 /* ImageDataCache.swift in Sources */, + D6D79F2B2A0D5D5C00AB2315 /* StatusEditCollectionViewCell.swift in Sources */, D6BC9DD7232D7811002CA326 /* TimelinesPageViewController.swift in Sources */, D6D9498D298CBB4000C59229 /* TrendingStatusCollectionViewCell.swift in Sources */, D60E2F2E244248BF005F8713 /* MastodonCachePersistentStore.swift in Sources */, @@ -2028,6 +2049,7 @@ D623A53D2635F5590095BD04 /* StatusPollView.swift in Sources */, D64AAE9726C88DC400FC57FB /* ToastConfiguration.swift in Sources */, D63661C02381C144004B9E16 /* PreferencesNavigationController.swift in Sources */, + D6D79F2F2A0D6A7F00AB2315 /* StatusEditPollView.swift in Sources */, D65B4B5E2973040D00DABDFB /* ReportAddStatusView.swift in Sources */, D6EE63FB2551F7F60065485C /* StatusCollapseButton.swift in Sources */, D6CA8CDE296387310050C433 /* SaveToPhotosActivity.swift in Sources */, @@ -2122,6 +2144,7 @@ D67C57AF21E28EAD00C3118B /* Array+Uniques.swift in Sources */, D68A76F129539116001DA1B3 /* FlipView.swift in Sources */, D6945C3223AC4D36005C403C /* HashtagTimelineViewController.swift in Sources */, + D6D79F2D2A0D61B400AB2315 /* StatusEditContentTextView.swift in Sources */, D647D92824257BEB0005044F /* AttachmentPreviewViewController.swift in Sources */, D61F75B5293BD97400C0B37F /* DeleteFilterService.swift in Sources */, D6DFC6A0242C4CCC00ACC392 /* Weak.swift in Sources */, @@ -2160,6 +2183,7 @@ D6E77D09286D25FA00D8B732 /* TrendingHashtagCollectionViewCell.swift in Sources */, D667E5F12134D5050057A976 /* UIViewController+Delegates.swift in Sources */, D6BC8748219738E1006163F1 /* EnhancedTableViewController.swift in Sources */, + D6D79F292A0D596B00AB2315 /* StatusEditHistoryViewController.swift in Sources */, D6E426B325337C7000C02E1C /* CustomEmojiImageView.swift in Sources */, D6D4DDD0212518A000E1C4BB /* AppDelegate.swift in Sources */, D61F75B3293BD89C00C0B37F /* UpdateFilterService.swift in Sources */, diff --git a/Tusker/CoreData/AccountMO.swift b/Tusker/CoreData/AccountMO.swift index 4d5effa8..001fab92 100644 --- a/Tusker/CoreData/AccountMO.swift +++ b/Tusker/CoreData/AccountMO.swift @@ -18,6 +18,12 @@ public final class AccountMO: NSManagedObject, AccountProtocol { return NSFetchRequest(entityName: "Account") } + @nonobjc public class func fetchRequest(url: URL) -> NSFetchRequest { + let req = NSFetchRequest(entityName: "Account") + req.predicate = NSPredicate(format: "url = %@", url as NSURL) + return req + } + @NSManaged public var acct: String @NSManaged public var avatar: URL? @NSManaged public var botCD: Bool diff --git a/Tusker/Screens/Status Edit History/StatusEditCollectionViewCell.swift b/Tusker/Screens/Status Edit History/StatusEditCollectionViewCell.swift new file mode 100644 index 00000000..f0077cb7 --- /dev/null +++ b/Tusker/Screens/Status Edit History/StatusEditCollectionViewCell.swift @@ -0,0 +1,165 @@ +// +// StatusEditCollectionViewCell.swift +// Tusker +// +// Created by Shadowfacts on 5/11/23. +// Copyright © 2023 Shadowfacts. All rights reserved. +// + +import UIKit +import Pachyderm + +@MainActor +protocol StatusEditCollectionViewCellDelegate: AnyObject, TuskerNavigationDelegate { + func statusEditCellNeedsReconfigure(_ cell: StatusEditCollectionViewCell, animated: Bool, completion: (() -> Void)?) +} + +class StatusEditCollectionViewCell: UICollectionViewListCell { + + private lazy var contentVStack = UIStackView(arrangedSubviews: [ + timestampLabel, + contentWarningLabel, + collapseButton, + contentContainer, + ]).configure { + $0.axis = .vertical + $0.spacing = 4 + $0.alignment = .fill + } + + private lazy var timestampLabel = UILabel().configure { + $0.textColor = .secondaryLabel + $0.font = UIFont(descriptor: .preferredFontDescriptor(withTextStyle: .body).addingAttributes([ + .traits: [ + UIFontDescriptor.TraitKey.weight: UIFont.Weight.light.rawValue, + ] + ]), size: 0) + $0.adjustsFontForContentSizeCategory = true + } + + private 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 + $0.setContentHuggingPriority(.defaultHigh, for: .vertical) + $0.isUserInteractionEnabled = true + $0.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(collapseButtonPressed))) + } + + private lazy var collapseButton = StatusCollapseButton(configuration: { + var config = UIButton.Configuration.filled() + config.image = UIImage(systemName: "chevron.down") + return config + }()).configure { + $0.tintAdjustmentMode = .normal + $0.setContentHuggingPriority(.defaultHigh, for: .vertical) + $0.addTarget(self, action: #selector(collapseButtonPressed), for: .touchUpInside) + } + + private let contentContainer = StatusContentContainer(useTopSpacer: false).configure { + $0.contentTextView.defaultFont = TimelineStatusCollectionViewCell.contentFont + $0.contentTextView.monospaceFont = TimelineStatusCollectionViewCell.monospaceFont + $0.contentTextView.paragraphStyle = TimelineStatusCollectionViewCell.contentParagraphStyle + $0.setContentHuggingPriority(.defaultLow, for: .vertical) + } + + weak var delegate: StatusEditCollectionViewCellDelegate? + private var mastodonController: MastodonController! { delegate?.apiController } + + private var edit: StatusEdit! + private var statusState: CollapseState! + + override init(frame: CGRect) { + super.init(frame: frame) + + contentVStack.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(contentVStack) + NSLayoutConstraint.activate([ + contentVStack.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 8), + contentVStack.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -6), + contentVStack.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16), + contentVStack.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -16), + ]) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // todo: accessibility + + + // MARK: Configure UI + + func updateUI(edit: StatusEdit, state: CollapseState, index: Int) { + self.edit = edit + self.statusState = state + + timestampLabel.text = ConversationMainStatusCollectionViewCell.dateFormatter.string(from: edit.createdAt) + + contentContainer.contentTextView.setTextFrom(edit: edit, index: index) + contentContainer.contentTextView.navigationDelegate = delegate + contentContainer.attachmentsView.delegate = self + contentContainer.attachmentsView.updateUI(attachments: edit.attachments) + contentContainer.pollView.isHidden = edit.poll == nil + contentContainer.pollView.updateUI(poll: edit.poll, emojis: edit.emojis) + contentContainer.cardView.isHidden = true + + contentWarningLabel.text = edit.spoilerText + contentWarningLabel.isHidden = edit.spoilerText.isEmpty + if !contentWarningLabel.isHidden { + contentWarningLabel.setEmojis(edit.emojis, identifier: index) + } + + _ = state.resolveFor(status: edit, height: { + layoutIfNeeded() + return contentContainer.visibleSubviewHeight + }) + collapseButton.isHidden = !state.collapsible! + contentContainer.setCollapsed(state.collapsed!) + if state.collapsed! { + contentContainer.alpha = 0 + // TODO: is this accessing the image view before the button's been laid out? + collapseButton.imageView!.transform = CGAffineTransform(rotationAngle: 0) + collapseButton.accessibilityLabel = NSLocalizedString("Expand Status", comment: "expand status button accessibility label") + } else { + contentContainer.alpha = 1 + collapseButton.imageView!.transform = CGAffineTransform(rotationAngle: .pi) + collapseButton.accessibilityLabel = NSLocalizedString("Collapse Status", comment: "collapse status button accessibility label") + } + } + + // MARK: Interaction + + @objc private func collapseButtonPressed() { + statusState.collapsed!.toggle() + contentContainer.layer.masksToBounds = true + delegate?.statusEditCellNeedsReconfigure(self, animated: true) { + self.contentContainer.layer.masksToBounds = false + } + } + +} + +extension StatusEditCollectionViewCell: AttachmentViewDelegate { + func attachmentViewGallery(startingAt index: Int) -> GalleryViewController? { + guard let delegate else { + return nil + } + let attachments = contentContainer.attachmentsView.attachments! + let sourceViews = attachments.map { + contentContainer.attachmentsView.getAttachmentView(for: $0) + } + let gallery = delegate.gallery(attachments: attachments, sourceViews: sourceViews, startIndex: index) + return gallery + } + + func attachmentViewPresent(_ vc: UIViewController, animated: Bool) { + delegate?.present(vc, animated: animated) + } +} diff --git a/Tusker/Screens/Status Edit History/StatusEditContentTextView.swift b/Tusker/Screens/Status Edit History/StatusEditContentTextView.swift new file mode 100644 index 00000000..b3c97552 --- /dev/null +++ b/Tusker/Screens/Status Edit History/StatusEditContentTextView.swift @@ -0,0 +1,22 @@ +// +// StatusEditContentTextView.swift +// Tusker +// +// Created by Shadowfacts on 5/11/23. +// Copyright © 2023 Shadowfacts. All rights reserved. +// + +import UIKit +import Pachyderm +import WebURL + +class StatusEditContentTextView: ContentTextView { + + func setTextFrom(edit: StatusEdit, index: Int) { + setTextFromHtml(edit.content) + setEmojis(edit.emojis, identifier: index) + } + + // mention links aren't included in the edit content, nothing else to do + +} diff --git a/Tusker/Screens/Status Edit History/StatusEditHistoryViewController.swift b/Tusker/Screens/Status Edit History/StatusEditHistoryViewController.swift new file mode 100644 index 00000000..10321b49 --- /dev/null +++ b/Tusker/Screens/Status Edit History/StatusEditHistoryViewController.swift @@ -0,0 +1,208 @@ +// +// StatusEditHistoryViewController.swift +// Tusker +// +// Created by Shadowfacts on 5/11/23. +// Copyright © 2023 Shadowfacts. All rights reserved. +// + +import UIKit +import Pachyderm + +class StatusEditHistoryViewController: UIViewController, CollectionViewController { + + private let statusID: String + private let mastodonController: MastodonController + + private var state = State.unloaded + + private(set) var collectionView: UICollectionView! + private var dataSource: UICollectionViewDiffableDataSource! + + init(statusID: String, mastodonController: MastodonController) { + self.statusID = statusID + self.mastodonController = mastodonController + + super.init(nibName: nil, bundle: nil) + + title = "Edit History" + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + + var config = UICollectionLayoutListConfiguration(appearance: .grouped) + config.backgroundColor = .appGroupedBackground + config.itemSeparatorHandler = { [unowned self] indexPath, sectionConfig in + guard let item = self.dataSource.itemIdentifier(for: indexPath) else { + return sectionConfig + } + var config = sectionConfig + if item.hideSeparators { + config.topSeparatorVisibility = .hidden + config.bottomSeparatorVisibility = .hidden + } + return config + } + let layout = UICollectionViewCompositionalLayout { sectionIndex, environment in + let section = NSCollectionLayoutSection.list(using: config, layoutEnvironment: environment) + if UIDevice.current.userInterfaceIdiom == .pad || UIDevice.current.userInterfaceIdiom == .mac { + section.contentInsetsReference = .readableContent + } + return section + } + collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout) + collectionView.delegate = self + collectionView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(collectionView) + NSLayoutConstraint.activate([ + collectionView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + collectionView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + collectionView.topAnchor.constraint(equalTo: view.topAnchor), + collectionView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + ]) + + dataSource = createDataSource() + } + + private func createDataSource() -> UICollectionViewDiffableDataSource { + let editCell = UICollectionView.CellRegistration { [unowned self] cell, indexPath, itemIdentifier in + cell.delegate = self + cell.updateUI(edit: itemIdentifier.0, state: itemIdentifier.1, index: itemIdentifier.2) + } + let loadingCell = UICollectionView.CellRegistration { cell, indexPath, itemIdentifier in + cell.indicator.startAnimating() + } + return UICollectionViewDiffableDataSource(collectionView: collectionView) { collectionView, indexPath, itemIdentifier in + switch itemIdentifier { + case .edit(let edit, let state, index: let index): + return collectionView.dequeueConfiguredReusableCell(using: editCell, for: indexPath, item: (edit, state, index)) + case .loadingIndicator: + return collectionView.dequeueConfiguredReusableCell(using: loadingCell, for: indexPath, item: ()) + } + } + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + clearSelectionOnAppear(animated: animated) + + if case .unloaded = state { + Task { + await load() + } + } + } + + private func load() async { + guard case .unloaded = state else { + return + } + state = .loading + + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([.edits]) + snapshot.appendItems([.loadingIndicator]) + await apply(snapshot) + + let req = Status.history(statusID) + do { + let (edits, _) = try await mastodonController.run(req) + + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([.edits]) + // show them in reverse chronological order + snapshot.appendItems(edits.reversed().enumerated().map { + .edit($1, .unknown, index: $0) + }) + await apply(snapshot) + + state = .loaded + + } catch { + state = .unloaded + let config = ToastConfiguration(from: error, with: "Error Loading Edits", in: self) { [weak self] toast in + toast.dismissToast(animated: true) + await self?.load() + } + self.showToast(configuration: config, animated: true) + } + } + + private func apply(_ snapshot: NSDiffableDataSourceSnapshot) async { + await MainActor.run { + self.dataSource.apply(snapshot) + } + } + + enum State { + case unloaded + case loading + case loaded + } +} + +extension StatusEditHistoryViewController { + enum Section { + case edits + } + enum Item: Hashable, Equatable { + case edit(StatusEdit, CollapseState, index: Int) + case loadingIndicator + + static func ==(lhs: Item, rhs: Item) -> Bool { + switch (lhs, rhs) { + case (.edit(_, _, let a), .edit(_, _, let b)): + return a == b + case (.loadingIndicator, .loadingIndicator): + return true + default: + return false + } + } + + func hash(into hasher: inout Hasher) { + switch self { + case .edit(_, _, index: let index): + hasher.combine(0) + hasher.combine(index) + case .loadingIndicator: + hasher.combine(1) + } + } + + var hideSeparators: Bool { + switch self { + case .loadingIndicator: + return true + default: + return false + } + } + } +} + +extension StatusEditHistoryViewController: UICollectionViewDelegate { + func collectionView(_ collectionView: UICollectionView, shouldSelectItemAt indexPath: IndexPath) -> Bool { + return false + } +} + +extension StatusEditHistoryViewController: TuskerNavigationDelegate { + nonisolated var apiController: MastodonController! { mastodonController } +} + +extension StatusEditHistoryViewController: StatusEditCollectionViewCellDelegate { + func statusEditCellNeedsReconfigure(_ cell: StatusEditCollectionViewCell, 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) + } + } +} diff --git a/Tusker/Screens/Status Edit History/StatusEditPollView.swift b/Tusker/Screens/Status Edit History/StatusEditPollView.swift new file mode 100644 index 00000000..0cc33ab6 --- /dev/null +++ b/Tusker/Screens/Status Edit History/StatusEditPollView.swift @@ -0,0 +1,47 @@ +// +// StatusEditPollView.swift +// Tusker +// +// Created by Shadowfacts on 5/11/23. +// Copyright © 2023 Shadowfacts. All rights reserved. +// + +import UIKit +import Pachyderm + +class StatusEditPollView: UIStackView { + + init() { + super.init(frame: .zero) + + axis = .vertical + alignment = .leading + spacing = 4 + } + + required init(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func updateUI(poll: StatusEdit.Poll?, emojis: [Emoji]) { + arrangedSubviews.forEach { $0.removeFromSuperview() } + + for option in poll?.options ?? [] { + // the edit poll doesn't actually include the multiple value + let icon = PollOptionCheckboxView(multiple: false) + icon.readOnly = false // this is a lie, but it's only used for stylistic changes + let label = EmojiLabel() + label.text = option.title + label.setEmojis(emojis, identifier: Optional.none) + let stack = UIStackView(arrangedSubviews: [ + icon, + label, + ]) + stack.axis = .horizontal + stack.alignment = .center + stack.spacing = 8 + addArrangedSubview(stack) + } + } + +} diff --git a/Tusker/Views/Attachments/AttachmentsContainerView.swift b/Tusker/Views/Attachments/AttachmentsContainerView.swift index c3b6aa3a..303ff31d 100644 --- a/Tusker/Views/Attachments/AttachmentsContainerView.swift +++ b/Tusker/Views/Attachments/AttachmentsContainerView.swift @@ -60,15 +60,15 @@ class AttachmentsContainerView: UIView { // MARK: - User Interaface - func updateUI(status: StatusMO) { - let showableAttachments = status.attachments.filter { AttachmentsContainerView.supportedAttachmentTypes.contains($0.kind) } - let newTokens = showableAttachments.map { AttachmentToken(attachment: $0) } + func updateUI(attachments: [Attachment]) { + let attachments = attachments.filter { AttachmentsContainerView.supportedAttachmentTypes.contains($0.kind) } + let newTokens = attachments.map { AttachmentToken(attachment: $0) } guard self.attachmentTokens != newTokens else { return } - self.attachments = showableAttachments + self.attachments = attachments self.attachmentTokens = newTokens attachmentViews.allObjects.forEach { $0.removeFromSuperview() } diff --git a/Tusker/Views/BaseEmojiLabel.swift b/Tusker/Views/BaseEmojiLabel.swift index 4cc760da..7b53cada 100644 --- a/Tusker/Views/BaseEmojiLabel.swift +++ b/Tusker/Views/BaseEmojiLabel.swift @@ -13,17 +13,18 @@ import WebURLFoundationExtras private let emojiRegex = try! NSRegularExpression(pattern: ":(\\w+):", options: []) protocol BaseEmojiLabel: AnyObject { - var emojiIdentifier: String? { get set } + var emojiIdentifier: AnyHashable? { get set } var emojiRequests: [ImageCache.Request] { get set } var emojiFont: UIFont { get } var emojiTextColor: UIColor { get } } extension BaseEmojiLabel { - func replaceEmojis(in attributedString: NSAttributedString, emojis: [Emoji], identifier: String?, setAttributedString: @escaping (_ attributedString: NSAttributedString, _ didReplaceEmojis: Bool) -> Void) { + func replaceEmojis(in attributedString: NSAttributedString, emojis: [Emoji], identifier: ID?, setAttributedString: @escaping (_ attributedString: NSAttributedString, _ didReplaceEmojis: Bool) -> Void) { // blergh precondition(Thread.isMainThread) + let identifier = AnyHashable(identifier) emojiIdentifier = identifier emojiRequests.forEach { $0.cancel() } emojiRequests = [] @@ -138,7 +139,7 @@ extension BaseEmojiLabel { } } - func replaceEmojis(in string: String, emojis: [Emoji], identifier: String?, setAttributedString: @escaping (_ attributedString: NSAttributedString, _ didReplaceEmojis: Bool) -> Void) { + func replaceEmojis(in string: String, emojis: [Emoji], identifier: ID?, setAttributedString: @escaping (_ attributedString: NSAttributedString, _ didReplaceEmojis: Bool) -> Void) { replaceEmojis(in: NSAttributedString(string: string), emojis: emojis, identifier: identifier, setAttributedString: setAttributedString) } } diff --git a/Tusker/Views/ContentTextView.swift b/Tusker/Views/ContentTextView.swift index 90cf2595..c3187ed6 100644 --- a/Tusker/Views/ContentTextView.swift +++ b/Tusker/Views/ContentTextView.swift @@ -42,7 +42,7 @@ class ContentTextView: LinkTextView, BaseEmojiLabel { private(set) var hasEmojis = false - var emojiIdentifier: String? + var emojiIdentifier: AnyHashable? var emojiRequests: [ImageCache.Request] = [] var emojiFont: UIFont { defaultFont } var emojiTextColor: UIColor { defaultColor } @@ -81,7 +81,7 @@ class ContentTextView: LinkTextView, BaseEmojiLabel { } // MARK: - Emojis - func setEmojis(_ emojis: [Emoji], identifier: String?) { + func setEmojis(_ emojis: [Emoji], identifier: ID?) { replaceEmojis(in: attributedText!, emojis: emojis, identifier: identifier) { attributedString, didReplaceEmojis in guard didReplaceEmojis else { return diff --git a/Tusker/Views/EmojiLabel.swift b/Tusker/Views/EmojiLabel.swift index fa199856..76750f2f 100644 --- a/Tusker/Views/EmojiLabel.swift +++ b/Tusker/Views/EmojiLabel.swift @@ -13,16 +13,16 @@ class EmojiLabel: UILabel, BaseEmojiLabel { private(set) var hasEmojis = false - var emojiIdentifier: String? + var emojiIdentifier: AnyHashable? var emojiRequests: [ImageCache.Request] = [] var emojiFont: UIFont { font } var emojiTextColor: UIColor { textColor } - func setEmojis(_ emojis: [Emoji], identifier: String) { + func setEmojis(_ emojis: [Emoji], identifier: ID?) { guard emojis.count > 0, let attributedText = attributedText else { return } replaceEmojis(in: attributedText, emojis: emojis, identifier: identifier) { [weak self] (newAttributedText, didReplaceEmojis) in - guard let self = self, self.emojiIdentifier == identifier else { return } + guard let self = self, self.emojiIdentifier == AnyHashable(identifier) else { return } self.hasEmojis = didReplaceEmojis self.attributedText = newAttributedText self.setNeedsLayout() diff --git a/Tusker/Views/MultiSourceEmojiLabel.swift b/Tusker/Views/MultiSourceEmojiLabel.swift index c6229ffe..35479f2a 100644 --- a/Tusker/Views/MultiSourceEmojiLabel.swift +++ b/Tusker/Views/MultiSourceEmojiLabel.swift @@ -12,14 +12,14 @@ import Pachyderm private let emojiRegex = try! NSRegularExpression(pattern: ":(\\w+):", options: []) class MultiSourceEmojiLabel: UILabel, BaseEmojiLabel { - var emojiIdentifier: String? + var emojiIdentifier: AnyHashable? var emojiRequests = [ImageCache.Request]() var emojiFont: UIFont { font } var emojiTextColor: UIColor { textColor } var combiner: (([NSAttributedString]) -> NSAttributedString)? - func setEmojis(pairs: [(String, [Emoji])], identifier: String) { + func setEmojis(pairs: [(String, [Emoji])], identifier: ID) { guard pairs.count > 0 else { return } self.emojiIdentifier = identifier @@ -40,7 +40,7 @@ class MultiSourceEmojiLabel: UILabel, BaseEmojiLabel { self.replaceEmojis(in: string, emojis: emojis, identifier: identifier) { (attributedString, _) in attributedStrings[index] = attributedString DispatchQueue.main.async { [weak self] in - guard let self = self, self.emojiIdentifier == identifier else { return } + guard let self = self, self.emojiIdentifier == AnyHashable(identifier) else { return } recombine() } } diff --git a/Tusker/Views/Poll/PollOptionCheckboxView.swift b/Tusker/Views/Poll/PollOptionCheckboxView.swift index cbbcc10f..36c1e144 100644 --- a/Tusker/Views/Poll/PollOptionCheckboxView.swift +++ b/Tusker/Views/Poll/PollOptionCheckboxView.swift @@ -59,6 +59,10 @@ class PollOptionCheckboxView: UIView { fatalError("init(coder:) has not been implemented") } + override var intrinsicContentSize: CGSize { + CGSize(width: 20, height: 20) + } + private func updateStyle() { imageView.isHidden = !isChecked if voted || readOnly { diff --git a/Tusker/Views/Poll/StatusPollView.swift b/Tusker/Views/Poll/StatusPollView.swift index 4dffc23e..7798a49d 100644 --- a/Tusker/Views/Poll/StatusPollView.swift +++ b/Tusker/Views/Poll/StatusPollView.swift @@ -145,18 +145,23 @@ class StatusPollView: UIView { } @objc private func votePressed() { + guard let statusID, + let poll else { + return + } + optionsView.isEnabled = false voteButton.isEnabled = false voteButton.disabledTitle = "Voted" UIImpactFeedbackGenerator(style: .light).impactOccurred() - let request = Poll.vote(poll!.id, choices: optionsView.checkedOptionIndices) + let request = Poll.vote(poll.id, choices: optionsView.checkedOptionIndices) mastodonController.run(request) { (response) in switch response { case let .failure(error): DispatchQueue.main.async { - self.updateUI(status: self.mastodonController.persistentContainer.status(for: self.statusID)!, poll: self.poll) + self.updateUI(status: self.mastodonController.persistentContainer.status(for: statusID)!, poll: poll) if let delegate = self.delegate { let config = ToastConfiguration(from: error, with: "Error Voting", in: delegate, retryAction: nil) @@ -167,7 +172,7 @@ class StatusPollView: UIView { case let .success(poll, _): let container = self.mastodonController.persistentContainer DispatchQueue.main.async { - guard let status = container.status(for: self.statusID) else { + guard let status = container.status(for: statusID) else { return } status.poll = poll diff --git a/Tusker/Views/Status/ConversationMainStatusCollectionViewCell.swift b/Tusker/Views/Status/ConversationMainStatusCollectionViewCell.swift index c75a757d..06df4174 100644 --- a/Tusker/Views/Status/ConversationMainStatusCollectionViewCell.swift +++ b/Tusker/Views/Status/ConversationMainStatusCollectionViewCell.swift @@ -116,7 +116,7 @@ class ConversationMainStatusCollectionViewCell: UICollectionViewListCell, Status $0.addTarget(self, action: #selector(collapseButtonPressed), for: .touchUpInside) } - let contentContainer = StatusContentContainer(useTopSpacer: true).configure { + let contentContainer = StatusContentContainer(useTopSpacer: true).configure { $0.contentTextView.defaultFont = ConversationMainStatusCollectionViewCell.contentFont $0.contentTextView.monospaceFont = ConversationMainStatusCollectionViewCell.monospaceFont $0.contentTextView.paragraphStyle = ConversationMainStatusCollectionViewCell.contentParagraphStyle @@ -154,8 +154,9 @@ class ConversationMainStatusCollectionViewCell: UICollectionViewListCell, Status $0.adjustsFontForContentSizeCategory = true } - private let editTimestampButton = UIButton().configure { + private lazy var editTimestampButton = UIButton().configure { $0.titleLabel!.adjustsFontForContentSizeCategory = true + $0.addTarget(self, action: #selector(editTimestampPressed), for: .touchUpInside) $0.isPointerInteractionEnabled = true } @@ -454,6 +455,10 @@ class ConversationMainStatusCollectionViewCell: UICollectionViewListCell, Status toggleReblog() } + @objc private func editTimestampPressed() { + delegate?.show(StatusEditHistoryViewController(statusID: statusID, mastodonController: mastodonController), sender: nil) + } + } private class ConversationMainStatusAccountDetailAccessibilityElement: UIAccessibilityElement { diff --git a/Tusker/Views/Status/StatusCollectionViewCell.swift b/Tusker/Views/Status/StatusCollectionViewCell.swift index bfcbfa7b..cbac1e3a 100644 --- a/Tusker/Views/Status/StatusCollectionViewCell.swift +++ b/Tusker/Views/Status/StatusCollectionViewCell.swift @@ -24,7 +24,7 @@ protocol StatusCollectionViewCell: UICollectionViewCell, AttachmentViewDelegate var usernameLabel: UILabel { get } var contentWarningLabel: EmojiLabel { get } var collapseButton: StatusCollapseButton { get } - var contentContainer: StatusContentContainer { get } + var contentContainer: StatusContentContainer { get } var replyButton: UIButton { get } var favoriteButton: UIButton { get } var reblogButton: UIButton { get } @@ -83,7 +83,7 @@ extension StatusCollectionViewCell { contentContainer.contentTextView.setTextFrom(status: status, precomputed: precomputedContent) contentContainer.contentTextView.navigationDelegate = delegate contentContainer.attachmentsView.delegate = self - contentContainer.attachmentsView.updateUI(status: status) + contentContainer.attachmentsView.updateUI(attachments: status.attachments) contentContainer.pollView.isHidden = status.poll == nil contentContainer.pollView.mastodonController = mastodonController contentContainer.pollView.delegate = delegate @@ -129,10 +129,12 @@ extension StatusCollectionViewCell { // do not include reply action here, because the cell already contains a button for it moreButton.menu = UIMenu(title: "", image: nil, identifier: nil, options: [], children: delegate?.actionsForStatus(status, source: .view(moreButton), includeStatusButtonActions: false) ?? []) - if statusState.unknown { + let didResolve = statusState.resolveFor(status: status) { // layout so that we can take the content height into consideration when deciding whether to collapse layoutIfNeeded() - statusState.resolveFor(status: status, height: contentContainer.visibleSubviewHeight) + return contentContainer.visibleSubviewHeight + } + if didResolve { if statusState.collapsible! && showStatusAutomatically { statusState.collapsed = false } diff --git a/Tusker/Views/Status/StatusContentContainer.swift b/Tusker/Views/Status/StatusContentContainer.swift index 2cbb7215..c80e30f8 100644 --- a/Tusker/Views/Status/StatusContentContainer.swift +++ b/Tusker/Views/Status/StatusContentContainer.swift @@ -8,7 +8,7 @@ import UIKit -class StatusContentContainer: UIView { +class StatusContentContainer: UIView { private var useTopSpacer = false private let topSpacer = UIView().configure { @@ -17,7 +17,7 @@ class StatusContentContainer: UIView { $0.heightAnchor.constraint(equalToConstant: 4).isActive = true } - let contentTextView = StatusContentTextView().configure { + let contentTextView = ContentView().configure { $0.adjustsFontForContentSizeCategory = true $0.isScrollEnabled = false $0.backgroundColor = nil @@ -33,7 +33,7 @@ class StatusContentContainer: UIView { let attachmentsView = AttachmentsContainerView() - let pollView = StatusPollView() + let pollView = PollView() private var arrangedSubviews: [UIView] { if useTopSpacer { diff --git a/Tusker/Views/Status/TimelineStatusCollectionViewCell.swift b/Tusker/Views/Status/TimelineStatusCollectionViewCell.swift index a7190f66..599117c2 100644 --- a/Tusker/Views/Status/TimelineStatusCollectionViewCell.swift +++ b/Tusker/Views/Status/TimelineStatusCollectionViewCell.swift @@ -178,7 +178,7 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell, StatusCollecti $0.addTarget(self, action: #selector(collapseButtonPressed), for: .touchUpInside) } - let contentContainer = StatusContentContainer(useTopSpacer: false).configure { + let contentContainer = StatusContentContainer(useTopSpacer: false).configure { $0.contentTextView.defaultFont = TimelineStatusCollectionViewCell.contentFont $0.contentTextView.monospaceFont = TimelineStatusCollectionViewCell.monospaceFont $0.contentTextView.paragraphStyle = TimelineStatusCollectionViewCell.contentParagraphStyle