diff --git a/CoreDataStack/Entity/Toot.swift b/CoreDataStack/Entity/Toot.swift index 3a2c91775..a2ad3b5a8 100644 --- a/CoreDataStack/Entity/Toot.swift +++ b/CoreDataStack/Entity/Toot.swift @@ -145,16 +145,19 @@ public extension Toot { return toot } + func update(reblogsCount: NSNumber) { if self.reblogsCount.intValue != reblogsCount.intValue { self.reblogsCount = reblogsCount } } + func update(favouritesCount: NSNumber) { if self.favouritesCount.intValue != favouritesCount.intValue { self.favouritesCount = favouritesCount } } + func update(repliesCount: NSNumber?) { guard let count = repliesCount else { return @@ -163,6 +166,13 @@ public extension Toot { self.repliesCount = repliesCount } } + + func update(replyTo: Toot?) { + if self.replyTo != replyTo { + self.replyTo = replyTo + } + } + func update(liked: Bool, mastodonUser: MastodonUser) { if liked { if !(self.favouritedBy ?? Set()).contains(mastodonUser) { @@ -174,6 +184,7 @@ public extension Toot { } } } + func update(reblogged: Bool, mastodonUser: MastodonUser) { if reblogged { if !(self.rebloggedBy ?? Set()).contains(mastodonUser) { diff --git a/Localization/app.json b/Localization/app.json index 123655955..5d86f1f81 100644 --- a/Localization/app.json +++ b/Localization/app.json @@ -37,6 +37,7 @@ }, "status": { "user_boosted": "%s boosted", + "user_replied_to": "Replied to %s", "show_post": "Show Post", "status_content_warning": "content warning", "media_content_warning": "Tap to reveal that may be sensitive", diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 5528d0234..6332da884 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -151,6 +151,9 @@ DB6C8C0F25F0A6AE00AAA452 /* Mastodon+Entity+Error.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6C8C0E25F0A6AE00AAA452 /* Mastodon+Entity+Error.swift */; }; DB71FD3625F8A16C00512AE1 /* APIService+Persist+PersistMemo.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB71FD3525F8A16C00512AE1 /* APIService+Persist+PersistMemo.swift */; }; DB71FD3C25F8A1C500512AE1 /* APIService+Persist+PersistCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB71FD3B25F8A1C500512AE1 /* APIService+Persist+PersistCache.swift */; }; + DB71FD4625F8C6D200512AE1 /* StatusProvider+UITableViewDataSourcePrefetching.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB71FD4525F8C6D200512AE1 /* StatusProvider+UITableViewDataSourcePrefetching.swift */; }; + DB71FD4C25F8C80E00512AE1 /* StatusPrefetchingService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB71FD4B25F8C80E00512AE1 /* StatusPrefetchingService.swift */; }; + DB71FD5225F8CCAA00512AE1 /* APIService+Status.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB71FD5125F8CCAA00512AE1 /* APIService+Status.swift */; }; DB72601C25E36A2100235243 /* MastodonServerRulesViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB72601B25E36A2100235243 /* MastodonServerRulesViewController.swift */; }; DB72602725E36A6F00235243 /* MastodonServerRulesViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB72602625E36A6F00235243 /* MastodonServerRulesViewModel.swift */; }; DB89B9F725C10FD0008580ED /* CoreDataStack.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DB89B9EE25C10FD0008580ED /* CoreDataStack.framework */; }; @@ -399,6 +402,9 @@ DB6C8C0E25F0A6AE00AAA452 /* Mastodon+Entity+Error.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Mastodon+Entity+Error.swift"; sourceTree = ""; }; DB71FD3525F8A16C00512AE1 /* APIService+Persist+PersistMemo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Persist+PersistMemo.swift"; sourceTree = ""; }; DB71FD3B25F8A1C500512AE1 /* APIService+Persist+PersistCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Persist+PersistCache.swift"; sourceTree = ""; }; + DB71FD4525F8C6D200512AE1 /* StatusProvider+UITableViewDataSourcePrefetching.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "StatusProvider+UITableViewDataSourcePrefetching.swift"; sourceTree = ""; }; + DB71FD4B25F8C80E00512AE1 /* StatusPrefetchingService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusPrefetchingService.swift; sourceTree = ""; }; + DB71FD5125F8CCAA00512AE1 /* APIService+Status.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Status.swift"; sourceTree = ""; }; DB72601B25E36A2100235243 /* MastodonServerRulesViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonServerRulesViewController.swift; sourceTree = ""; }; DB72602625E36A6F00235243 /* MastodonServerRulesViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonServerRulesViewModel.swift; sourceTree = ""; }; DB89B9EE25C10FD0008580ED /* CoreDataStack.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = CoreDataStack.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -606,6 +612,7 @@ 2DF75B9A25D0E27500694EC8 /* StatusProviderFacade.swift */, 2DF75BA025D0E29D00694EC8 /* StatusProvider+StatusTableViewCellDelegate.swift */, DB59F0FD25EF5D96001F1DAB /* StatusProvider+UITableViewDelegate.swift */, + DB71FD4525F8C6D200512AE1 /* StatusProvider+UITableViewDataSourcePrefetching.swift */, ); path = StatusProvider; sourceTree = ""; @@ -655,6 +662,7 @@ DB4563BC25E11A24004DA0B9 /* KeyboardResponderService.swift */, 2D206B8B25F6015000143C56 /* AudioPlayer.swift */, 2DA6054625F716A2006356F9 /* PlaybackState.swift */, + DB71FD4B25F8C80E00512AE1 /* StatusPrefetchingService.swift */, ); path = Service; sourceTree = ""; @@ -933,6 +941,7 @@ DB45FB1C25CA9D23005A8AC7 /* APIService+HomeTimeline.swift */, DB0AC6FB25CD02E600D75117 /* APIService+Instance.swift */, DB59F10D25EF724F001F1DAB /* APIService+Poll.swift */, + DB71FD5125F8CCAA00512AE1 /* APIService+Status.swift */, ); path = APIService; sourceTree = ""; @@ -1566,6 +1575,7 @@ 2D61335E25C1894B00CAE157 /* APIService.swift in Sources */, 2D38F1F725CD47AC00561493 /* HomeTimelineViewModel+LoadOldestState.swift in Sources */, 0FB3D33225E5F50E00AAD544 /* PickServerSearchCell.swift in Sources */, + DB71FD5225F8CCAA00512AE1 /* APIService+Status.swift in Sources */, 2D5A3D3825CF8D9F002347D6 /* ScrollViewContainer.swift in Sources */, DB1E347825F519300079D7DF /* PickServerItem.swift in Sources */, DB1FD45A25F27898004CFCFC /* CategoryPickerItem.swift in Sources */, @@ -1577,6 +1587,7 @@ 2D38F1F125CD477D00561493 /* HomeTimelineViewModel+LoadMiddleState.swift in Sources */, DB68A06325E905E000CFDF14 /* UIApplication.swift in Sources */, DB45FAD725CA6C76005A8AC7 /* UIBarButtonItem.swift in Sources */, + DB71FD4625F8C6D200512AE1 /* StatusProvider+UITableViewDataSourcePrefetching.swift in Sources */, 2D152A8C25C295CC009AA50C /* StatusView.swift in Sources */, 2D42FF8525C8224F004A627A /* HitTestExpandedButton.swift in Sources */, DB72601C25E36A2100235243 /* MastodonServerRulesViewController.swift in Sources */, @@ -1589,6 +1600,7 @@ DB0AC6FC25CD02E600D75117 /* APIService+Instance.swift in Sources */, 2D69D00A25CAA00300C3A1B2 /* APIService+CoreData+Toot.swift in Sources */, DB4481C625EE2ADA00BEFB67 /* PollSection.swift in Sources */, + DB71FD4C25F8C80E00512AE1 /* StatusPrefetchingService.swift in Sources */, 2D939AB525EDD8A90076FA61 /* String.swift in Sources */, DB4481B925EE289600BEFB67 /* UITableView.swift in Sources */, 0FAA101C25E10E760017CCDE /* UIFont.swift in Sources */, diff --git a/Mastodon/Diffiable/Section/StatusSection.swift b/Mastodon/Diffiable/Section/StatusSection.swift index 489b9d3b8..a6b9397ad 100644 --- a/Mastodon/Diffiable/Section/StatusSection.swift +++ b/Mastodon/Diffiable/Section/StatusSection.swift @@ -79,12 +79,17 @@ extension StatusSection { statusItemAttribute: Item.StatusAttribute ) { // set header - cell.statusView.headerContainerStackView.isHidden = toot.reblog == nil - cell.statusView.headerInfoLabel.text = { - let author = toot.author - let name = author.displayName.isEmpty ? author.username : author.displayName - return L10n.Common.Controls.Status.userBoosted(name) - }() + StatusSection.configureHeader(cell: cell, toot: toot) + ManagedObjectObserver.observe(object: toot) + .receive(on: DispatchQueue.main) + .sink { _ in + // do nothing + } receiveValue: { change in + guard case .update(let object) = change.changeType, + let newToot = object as? Toot else { return } + StatusSection.configureHeader(cell: cell, toot: newToot) + } + .store(in: &cell.disposeBag) // set name username avatar cell.statusView.nameLabel.text = { @@ -225,7 +230,6 @@ extension StatusSection { guard case .update(let object) = change.changeType, let newToot = object as? Toot else { return } let targetToot = newToot.reblog ?? newToot - let isLike = targetToot.favouritedBy.flatMap { $0.contains(where: { $0.id == requestUserID }) } ?? false let favoriteCount = targetToot.favouritesCount.intValue let favoriteCountTitle = StatusSection.formattedNumberTitleForActionButton(favoriteCount) @@ -236,6 +240,31 @@ extension StatusSection { .store(in: &cell.disposeBag) } + static func configureHeader( + cell: StatusTableViewCell, + toot: Toot + ) { + if toot.reblog != nil { + cell.statusView.headerContainerStackView.isHidden = false + cell.statusView.headerInfoLabel.attributedText = StatusView.iconAttributedString(image: StatusView.boostIconImage) + cell.statusView.headerInfoLabel.text = { + let author = toot.author + let name = author.displayName.isEmpty ? author.username : author.displayName + return L10n.Common.Controls.Status.userBoosted(name) + }() + } else if let replyTo = toot.replyTo { + cell.statusView.headerContainerStackView.isHidden = false + cell.statusView.headerInfoLabel.attributedText = StatusView.iconAttributedString(image: StatusView.replyIconImage) + cell.statusView.headerInfoLabel.text = { + let author = replyTo.author + let name = author.displayName.isEmpty ? author.username : author.displayName + return L10n.Common.Controls.Status.userRepliedTo(name) + }() + } else { + cell.statusView.headerContainerStackView.isHidden = true + } + } + static func configure( cell: StatusTableViewCell, poll: Poll?, diff --git a/Mastodon/Generated/Strings.swift b/Mastodon/Generated/Strings.swift index 7c595918e..74595d5b6 100644 --- a/Mastodon/Generated/Strings.swift +++ b/Mastodon/Generated/Strings.swift @@ -80,6 +80,10 @@ internal enum L10n { internal static func userBoosted(_ p1: Any) -> String { return L10n.tr("Localizable", "Common.Controls.Status.UserBoosted", String(describing: p1)) } + /// Replied to %@ + internal static func userRepliedTo(_ p1: Any) -> String { + return L10n.tr("Localizable", "Common.Controls.Status.UserRepliedTo", String(describing: p1)) + } internal enum Poll { /// Closed internal static let closed = L10n.tr("Localizable", "Common.Controls.Status.Poll.Closed") diff --git a/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift b/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift index cd4e5160d..12dfeadf5 100644 --- a/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift +++ b/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift @@ -119,7 +119,7 @@ extension StatusTableViewCellDelegate where Self: StatusProvider { guard let diffableDataSource = cell.statusView.pollTableViewDataSource else { return } let item = diffableDataSource.itemIdentifier(for: indexPath) - guard case let .opion(objectID, attribute) = item else { return } + guard case let .opion(objectID, _) = item else { return } guard let option = managedObjectContext.object(with: objectID) as? PollOption else { return } let poll = option.poll diff --git a/Mastodon/Protocol/StatusProvider/StatusProvider+UITableViewDataSourcePrefetching.swift b/Mastodon/Protocol/StatusProvider/StatusProvider+UITableViewDataSourcePrefetching.swift new file mode 100644 index 000000000..20f8e30a9 --- /dev/null +++ b/Mastodon/Protocol/StatusProvider/StatusProvider+UITableViewDataSourcePrefetching.swift @@ -0,0 +1,50 @@ +// +// StatusProvider+UITableViewDataSourcePrefetching.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-10. +// + +import UIKit +import CoreData +import CoreDataStack + +extension StatusTableViewCellDelegate where Self: StatusProvider { + func handleTableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) { + // prefetch reply toot + guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { return } + let domain = activeMastodonAuthenticationBox.domain + + var statusObjectIDs: [NSManagedObjectID] = [] + for item in items(indexPaths: indexPaths) { + switch item { + case .homeTimelineIndex(let objectID, _): + let homeTimelineIndex = managedObjectContext.object(with: objectID) as! HomeTimelineIndex + statusObjectIDs.append(homeTimelineIndex.toot.objectID) + case .toot(let objectID, _): + statusObjectIDs.append(objectID) + default: + continue + } + } + + let backgroundManagedObjectContext = context.backgroundManagedObjectContext + backgroundManagedObjectContext.perform { [weak self] in + guard let self = self else { return } + for objectID in statusObjectIDs { + let toot = backgroundManagedObjectContext.object(with: objectID) as! Toot + guard let replyToID = toot.inReplyToID, toot.replyTo == nil else { + // skip + continue + } + self.context.statusPrefetchingService.prefetchReplyTo( + domain: domain, + statusObjectID: toot.objectID, + statusID: toot.id, + replyToStatusID: replyToID, + authorizationBox: activeMastodonAuthenticationBox + ) + } + } + } +} diff --git a/Mastodon/Protocol/StatusProvider/StatusProvider.swift b/Mastodon/Protocol/StatusProvider/StatusProvider.swift index a0a7116fc..4ec0d1e16 100644 --- a/Mastodon/Protocol/StatusProvider/StatusProvider.swift +++ b/Mastodon/Protocol/StatusProvider/StatusProvider.swift @@ -20,4 +20,5 @@ protocol StatusProvider: NeedsDependency & DisposeBagCollectable & UIViewControl var managedObjectContext: NSManagedObjectContext { get } var tableViewDiffableDataSource: UITableViewDiffableDataSource? { get } func item(for cell: UITableViewCell?, indexPath: IndexPath?) -> Item? + func items(indexPaths: [IndexPath]) -> [Item] } diff --git a/Mastodon/Resources/en.lproj/Localizable.strings b/Mastodon/Resources/en.lproj/Localizable.strings index 54b69e274..a1a6db78e 100644 --- a/Mastodon/Resources/en.lproj/Localizable.strings +++ b/Mastodon/Resources/en.lproj/Localizable.strings @@ -31,6 +31,7 @@ "Common.Controls.Status.ShowPost" = "Show Post"; "Common.Controls.Status.StatusContentWarning" = "content warning"; "Common.Controls.Status.UserBoosted" = "%@ boosted"; +"Common.Controls.Status.UserRepliedTo" = "Replied to %@"; "Common.Controls.Timeline.LoadMore" = "Load More"; "Common.Countable.Photo.Multiple" = "photos"; "Common.Countable.Photo.Single" = "photo"; diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+StatusProvider.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+StatusProvider.swift index a0d9204ba..dc8eb4803 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+StatusProvider.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+StatusProvider.swift @@ -70,4 +70,18 @@ extension HomeTimelineViewController: StatusProvider { return item } + func items(indexPaths: [IndexPath]) -> [Item] { + guard let diffableDataSource = self.viewModel.diffableDataSource else { + assertionFailure() + return [] + } + + var items: [Item] = [] + for indexPath in indexPaths { + guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { continue } + items.append(item) + } + return items + } + } diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift index b9d0f94e1..db285c2c3 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift @@ -103,6 +103,7 @@ extension HomeTimelineViewController { viewModel.tableView = tableView viewModel.contentOffsetAdjustableTimelineViewControllerDelegate = self tableView.delegate = self + tableView.prefetchDataSource = self viewModel.setupDiffableDataSource( for: tableView, dependency: self, @@ -239,6 +240,13 @@ extension HomeTimelineViewController: UITableViewDelegate { } +// MARK: - UITableViewDataSourcePrefetching +extension HomeTimelineViewController: UITableViewDataSourcePrefetching { + func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) { + handleTableView(tableView, prefetchRowsAt: indexPaths) + } +} + // MARK: - ContentOffsetAdjustableTimelineViewControllerDelegate extension HomeTimelineViewController: ContentOffsetAdjustableTimelineViewControllerDelegate { func navigationBar() -> UINavigationBar? { diff --git a/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewController.swift b/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewController.swift index f3e811b9f..37e8b0dab 100644 --- a/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewController.swift +++ b/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewController.swift @@ -375,7 +375,7 @@ extension MastodonPickServerViewController: UITableViewDelegate { func tableView(_ tableView: UITableView, willSelectRowAt indexPath: IndexPath) -> IndexPath? { guard let diffableDataSource = viewModel.diffableDataSource else { return nil } guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return nil } - guard case let .server(server) = item else { return nil } + guard case .server = item else { return nil } if tableView.indexPathForSelectedRow == indexPath { tableView.deselectRow(at: indexPath, animated: false) diff --git a/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewModel+LoadIndexedServerState.swift b/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewModel+LoadIndexedServerState.swift index b9cdcc7e4..69f6a82fb 100644 --- a/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewModel+LoadIndexedServerState.swift +++ b/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewModel+LoadIndexedServerState.swift @@ -45,7 +45,7 @@ extension MastodonPickServerViewModel.LoadIndexedServerState { viewModel.context.apiService.servers(language: nil, category: nil) .sink { completion in switch completion { - case .failure(let error): + case .failure: // TODO: handle error stateMachine.enter(Fail.self) case .finished: @@ -84,7 +84,7 @@ extension MastodonPickServerViewModel.LoadIndexedServerState { override func didEnter(from previousState: GKState?) { super.didEnter(from: previousState) - guard let viewModel = self.viewModel, let stateMachine = self.stateMachine else { return } + guard let viewModel = self.viewModel else { return } viewModel.isLoadingIndexedServers.value = false } } diff --git a/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewModel.swift b/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewModel.swift index e136e86ce..09b6c327c 100644 --- a/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewModel.swift +++ b/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewModel.swift @@ -176,7 +176,7 @@ class MastodonPickServerViewModel: NSObject { switch result { case .success(let response): self.unindexedServers.send(response.value) - case .failure(let error): + case .failure: // TODO: What should be presented when user inputs invalid search text? self.unindexedServers.send([]) } diff --git a/Mastodon/Scene/PublicTimeline/PublicTimelineViewController+StatusProvider.swift b/Mastodon/Scene/PublicTimeline/PublicTimelineViewController+StatusProvider.swift index aceb83718..7d52a5764 100644 --- a/Mastodon/Scene/PublicTimeline/PublicTimelineViewController+StatusProvider.swift +++ b/Mastodon/Scene/PublicTimeline/PublicTimelineViewController+StatusProvider.swift @@ -70,4 +70,18 @@ extension PublicTimelineViewController: StatusProvider { return item } + func items(indexPaths: [IndexPath]) -> [Item] { + guard let diffableDataSource = self.viewModel.diffableDataSource else { + assertionFailure() + return [] + } + + var items: [Item] = [] + for indexPath in indexPaths { + guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { continue } + items.append(item) + } + return items + } + } diff --git a/Mastodon/Scene/PublicTimeline/PublicTimelineViewController.swift b/Mastodon/Scene/PublicTimeline/PublicTimelineViewController.swift index 98d2dbd94..ddca93fe7 100644 --- a/Mastodon/Scene/PublicTimeline/PublicTimelineViewController.swift +++ b/Mastodon/Scene/PublicTimeline/PublicTimelineViewController.swift @@ -73,6 +73,7 @@ extension PublicTimelineViewController { viewModel.tableView = tableView viewModel.contentOffsetAdjustableTimelineViewControllerDelegate = self tableView.delegate = self + tableView.prefetchDataSource = self viewModel.setupDiffableDataSource( for: tableView, dependency: self, @@ -125,6 +126,13 @@ extension PublicTimelineViewController: UITableViewDelegate { } } +// MARK: - UITableViewDataSourcePrefetching +extension PublicTimelineViewController: UITableViewDataSourcePrefetching { + func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) { + handleTableView(tableView, prefetchRowsAt: indexPaths) + } +} + // MARK: - ContentOffsetAdjustableTimelineViewControllerDelegate extension PublicTimelineViewController: ContentOffsetAdjustableTimelineViewControllerDelegate { func navigationBar() -> UINavigationBar? { diff --git a/Mastodon/Scene/Share/View/Content/StatusView.swift b/Mastodon/Scene/Share/View/Content/StatusView.swift index 2713647fe..f908ce5c1 100644 --- a/Mastodon/Scene/Share/View/Content/StatusView.swift +++ b/Mastodon/Scene/Share/View/Content/StatusView.swift @@ -24,6 +24,29 @@ final class StatusView: UIView { static let avatarImageCornerRadius: CGFloat = 4 static let contentWarningBlurRadius: CGFloat = 12 + static let boostIconImage: UIImage = { + let font = UIFont.systemFont(ofSize: 13, weight: .medium) + let configuration = UIImage.SymbolConfiguration(font: font) + let image = UIImage(systemName: "arrow.2.squarepath", withConfiguration: configuration)!.withTintColor(Asset.Colors.Label.secondary.color) + return image + }() + + static let replyIconImage: UIImage = { + let font = UIFont.systemFont(ofSize: 13, weight: .medium) + let configuration = UIImage.SymbolConfiguration(font: font) + let image = UIImage(systemName: "arrowshape.turn.up.left.fill", withConfiguration: configuration)!.withTintColor(Asset.Colors.Label.secondary.color) + return image + }() + + static func iconAttributedString(image: UIImage) -> NSAttributedString { + let attributedString = NSMutableAttributedString() + let imageTextAttachment = NSTextAttachment() + let imageAttribute = NSAttributedString(attachment: imageTextAttachment) + imageTextAttachment.image = image + attributedString.append(imageAttribute) + return attributedString + } + weak var delegate: StatusViewDelegate? var isStatusTextSensitive = false var pollTableViewDataSource: UITableViewDiffableDataSource? @@ -33,14 +56,7 @@ final class StatusView: UIView { let headerIconLabel: UILabel = { let label = UILabel() - let attributedString = NSMutableAttributedString() - let imageTextAttachment = NSTextAttachment() - let font = UIFont.systemFont(ofSize: 13, weight: .medium) - let configuration = UIImage.SymbolConfiguration(font: font) - imageTextAttachment.image = UIImage(systemName: "arrow.2.squarepath", withConfiguration: configuration)?.withTintColor(Asset.Colors.Label.secondary.color) - let imageAttribute = NSAttributedString(attachment: imageTextAttachment) - attributedString.append(imageAttribute) - label.attributedText = attributedString + label.attributedText = StatusView.iconAttributedString(image: StatusView.boostIconImage) return label }() diff --git a/Mastodon/Service/APIService/APIService+Status.swift b/Mastodon/Service/APIService/APIService+Status.swift new file mode 100644 index 000000000..88ac064af --- /dev/null +++ b/Mastodon/Service/APIService/APIService+Status.swift @@ -0,0 +1,54 @@ +// +// APIService+Status.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-10. +// + +import Foundation +import Combine +import CoreData +import CoreDataStack +import CommonOSLog +import DateToolsSwift +import MastodonSDK + +extension APIService { + + func status( + domain: String, + statusID: Mastodon.Entity.Status.ID, + authorizationBox: AuthenticationService.MastodonAuthenticationBox + ) -> AnyPublisher, Error> { + let authorization = authorizationBox.userAuthorization + return Mastodon.API.Statuses.status( + session: session, + domain: domain, + statusID: statusID, + authorization: authorization + ) + .flatMap { response -> AnyPublisher, Error> in + return APIService.Persist.persistToots( + managedObjectContext: self.backgroundManagedObjectContext, + domain: domain, + query: nil, + response: response.map { [$0] }, + persistType: .lookUp, + requestMastodonUserID: nil, + log: OSLog.api + ) + .setFailureType(to: Error.self) + .tryMap { result -> Mastodon.Response.Content in + switch result { + case .success: + return response + case .failure(let error): + throw error + } + } + .eraseToAnyPublisher() + } + .eraseToAnyPublisher() + } + +} diff --git a/Mastodon/Service/APIService/Persist/APIService+Persist+Toot.swift b/Mastodon/Service/APIService/Persist/APIService+Persist+Toot.swift index 1a3cd2985..fb2c2e6c0 100644 --- a/Mastodon/Service/APIService/Persist/APIService+Persist+Toot.swift +++ b/Mastodon/Service/APIService/Persist/APIService+Persist+Toot.swift @@ -19,12 +19,13 @@ extension APIService.Persist { case `public` case home case likeList + case lookUp } static func persistToots( managedObjectContext: NSManagedObjectContext, domain: String, - query: Mastodon.API.Timeline.TimelineQuery, + query: Mastodon.API.Timeline.TimelineQuery?, response: Mastodon.Response.Content<[Mastodon.Entity.Status]>, persistType: PersistTimelineType, requestMastodonUserID: MastodonUser.ID?, // could be nil when response from public endpoint @@ -122,6 +123,7 @@ extension APIService.Persist { case .home: return .homeTimeline case .public: return .publicTimeline case .likeList: return .likeList + case .lookUp: return .lookUp } }() @@ -152,7 +154,8 @@ extension APIService.Persist { // home timeline tasks switch persistType { case .home: - guard let requestMastodonUserID = requestMastodonUserID else { + guard let query = query, + let requestMastodonUserID = requestMastodonUserID else { assertionFailure() return } diff --git a/Mastodon/Service/StatusPrefetchingService.swift b/Mastodon/Service/StatusPrefetchingService.swift new file mode 100644 index 000000000..d4332fe16 --- /dev/null +++ b/Mastodon/Service/StatusPrefetchingService.swift @@ -0,0 +1,84 @@ +// +// StatusPrefetchingService.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-10. +// + +import os.log +import Foundation +import Combine +import CoreData +import CoreDataStack +import MastodonSDK + +final class StatusPrefetchingService { + + typealias TaskID = String + + let workingQueue = DispatchQueue(label: "status-prefetching-service-working-queue") + + var disposeBag = Set() + private(set) var statusPrefetchingDisposeBagDict: [TaskID: AnyCancellable] = [:] + + weak var apiService: APIService? + + init(apiService: APIService) { + self.apiService = apiService + } + +} + +extension StatusPrefetchingService { + + func prefetchReplyTo( + domain: String, + statusObjectID: NSManagedObjectID, + statusID: Mastodon.Entity.Status.ID, + replyToStatusID: Mastodon.Entity.Status.ID, + authorizationBox: AuthenticationService.MastodonAuthenticationBox + ) { + workingQueue.async { [weak self] in + guard let self = self, let apiService = self.apiService else { return } + let taskID = domain + "@" + statusID + "->" + replyToStatusID + guard self.statusPrefetchingDisposeBagDict[taskID] == nil else { return } + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: prefetching replyTo: %s", ((#file as NSString).lastPathComponent), #line, #function, taskID) + + self.statusPrefetchingDisposeBagDict[taskID] = apiService.status( + domain: domain, + statusID: replyToStatusID, + authorizationBox: authorizationBox + ) + .sink(receiveCompletion: { [weak self] completion in + // remove task when completed + guard let self = self else { return } + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: prefeched replyTo: %s", ((#file as NSString).lastPathComponent), #line, #function, taskID) + self.statusPrefetchingDisposeBagDict[taskID] = nil + }, receiveValue: { [weak self] _ in + guard let self = self else { return } + let backgroundManagedObjectContext = apiService.backgroundManagedObjectContext + backgroundManagedObjectContext.performChanges { + guard let status = backgroundManagedObjectContext.object(with: statusObjectID) as? Toot else { return } + do { + let predicate = Toot.predicate(domain: domain, id: replyToStatusID) + let request = Toot.sortedFetchRequest + request.predicate = predicate + request.returnsObjectsAsFaults = false + request.fetchLimit = 1 + guard let replyTo = try backgroundManagedObjectContext.fetch(request).first else { return } + status.update(replyTo: replyTo) + } catch { + assertionFailure(error.localizedDescription) + } + } + .sink { _ in + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: update status replyTo: %s", ((#file as NSString).lastPathComponent), #line, #function, taskID) + } receiveValue: { _ in + // do nothing + } + .store(in: &self.disposeBag) + }) + } + } + +} diff --git a/Mastodon/State/AppContext.swift b/Mastodon/State/AppContext.swift index 08918496b..ffa1321ab 100644 --- a/Mastodon/State/AppContext.swift +++ b/Mastodon/State/AppContext.swift @@ -24,6 +24,8 @@ class AppContext: ObservableObject { let apiService: APIService let authenticationService: AuthenticationService + let statusPrefetchingService: StatusPrefetchingService + let documentStore: DocumentStore private var documentStoreSubscription: AnyCancellable! @@ -46,6 +48,10 @@ class AppContext: ObservableObject { apiService: _apiService ) + statusPrefetchingService = StatusPrefetchingService( + apiService: _apiService + ) + documentStore = DocumentStore() documentStoreSubscription = documentStore.objectWillChange .receive(on: DispatchQueue.main) diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Statuses.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Statuses.swift new file mode 100644 index 000000000..cb33bc09b --- /dev/null +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Statuses.swift @@ -0,0 +1,53 @@ +// +// Mastodon+API+Statuses.swift +// +// +// Created by MainasuK Cirno on 2021-3-10. +// + +import Foundation +import Combine + +extension Mastodon.API.Statuses { + + static func viewStatusEndpointURL(domain: String, statusID: Mastodon.Entity.Status.ID) -> URL { + let pathComponent = "statuses/" + statusID + return Mastodon.API.endpointURL(domain: domain).appendingPathComponent(pathComponent) + } + + /// View specific status + /// + /// View information about a status + /// + /// - Since: 0.0.0 + /// - Version: 3.3.0 + /// # Last Update + /// 2021/3/10 + /// # Reference + /// [Document](https://docs.joinmastodon.org/methods/statuses/) + /// - Parameters: + /// - session: `URLSession` + /// - domain: Mastodon instance domain. e.g. "example.com" + /// - statusID: id for status + /// - authorization: User token. Could be nil if status is public + /// - Returns: `AnyPublisher` contains `Status` nested in the response + public static func status( + session: URLSession, + domain: String, + statusID: Mastodon.Entity.Poll.ID, + authorization: Mastodon.API.OAuth.Authorization? + ) -> AnyPublisher, Error> { + let request = Mastodon.API.get( + url: viewStatusEndpointURL(domain: domain, statusID: statusID), + query: nil, + authorization: authorization + ) + return session.dataTaskPublisher(for: request) + .tryMap { data, response in + let value = try Mastodon.API.decode(type: Mastodon.Entity.Status.self, from: data, response: response) + return Mastodon.Response.Content(value: value, response: response) + } + .eraseToAnyPublisher() + } + +} diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift index 073d926e9..5516a0492 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift @@ -95,6 +95,7 @@ extension Mastodon.API { public enum OAuth { } public enum Onboarding { } public enum Polls { } + public enum Statuses { } public enum Timeline { } public enum Favorites { } }