From 441a6aee9e0286f389d23083b43a0bb7c3c79885 Mon Sep 17 00:00:00 2001 From: CMK Date: Tue, 9 Mar 2021 15:18:43 +0800 Subject: [PATCH 01/20] feat: implement boost for toot --- CoreDataStack/Entity/Toot.swift | 8 +- Mastodon.xcodeproj/project.pbxproj | 4 + .../Diffiable/Section/StatusSection.swift | 65 +++--- Mastodon/Generated/Assets.swift | 1 + ...Provider+StatusTableViewCellDelegate.swift | 4 + .../StatusProvider+UITableViewDelegate.swift | 1 + .../StatusProvider/StatusProviderFacade.swift | 110 ++++++++++- .../system.green.colorset/Contents.json | 20 ++ .../TableviewCell/StatusTableViewCell.swift | 5 +- .../View/ToolBar/ActionToolBarContainer.swift | 69 ++++--- .../APIService/APIService+Favorite.swift | 5 +- .../APIService/APIService+Reblog.swift | 169 ++++++++++++++++ .../API/Mastodon+API+Favorites.swift | 6 +- .../API/Mastodon+API+Status+Reblog.swift | 186 ++++++++++++++++++ .../MastodonSDK/API/Mastodon+API+Status.swift | 12 ++ .../MastodonSDK/API/Mastodon+API.swift | 1 + 16 files changed, 598 insertions(+), 68 deletions(-) create mode 100644 Mastodon/Resources/Assets.xcassets/Colors/system.green.colorset/Contents.json create mode 100644 Mastodon/Service/APIService/APIService+Reblog.swift create mode 100644 MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Status+Reblog.swift create mode 100644 MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Status.swift diff --git a/CoreDataStack/Entity/Toot.swift b/CoreDataStack/Entity/Toot.swift index c5fcf486..56d112d7 100644 --- a/CoreDataStack/Entity/Toot.swift +++ b/CoreDataStack/Entity/Toot.swift @@ -163,7 +163,7 @@ public extension Toot { func update(liked: Bool, mastodonUser: MastodonUser) { if liked { if !(self.favouritedBy ?? Set()).contains(mastodonUser) { - self.mutableSetValue(forKey: #keyPath(Toot.favouritedBy)).addObjects(from: [mastodonUser]) + self.mutableSetValue(forKey: #keyPath(Toot.favouritedBy)).add(mastodonUser) } } else { if (self.favouritedBy ?? Set()).contains(mastodonUser) { @@ -174,7 +174,7 @@ public extension Toot { func update(reblogged: Bool, mastodonUser: MastodonUser) { if reblogged { if !(self.rebloggedBy ?? Set()).contains(mastodonUser) { - self.mutableSetValue(forKey: #keyPath(Toot.rebloggedBy)).addObjects(from: [mastodonUser]) + self.mutableSetValue(forKey: #keyPath(Toot.rebloggedBy)).add(mastodonUser) } } else { if (self.rebloggedBy ?? Set()).contains(mastodonUser) { @@ -186,7 +186,7 @@ public extension Toot { func update(muted: Bool, mastodonUser: MastodonUser) { if muted { if !(self.mutedBy ?? Set()).contains(mastodonUser) { - self.mutableSetValue(forKey: #keyPath(Toot.mutedBy)).addObjects(from: [mastodonUser]) + self.mutableSetValue(forKey: #keyPath(Toot.mutedBy)).add(mastodonUser) } } else { if (self.mutedBy ?? Set()).contains(mastodonUser) { @@ -198,7 +198,7 @@ public extension Toot { func update(bookmarked: Bool, mastodonUser: MastodonUser) { if bookmarked { if !(self.bookmarkedBy ?? Set()).contains(mastodonUser) { - self.mutableSetValue(forKey: #keyPath(Toot.bookmarkedBy)).addObjects(from: [mastodonUser]) + self.mutableSetValue(forKey: #keyPath(Toot.bookmarkedBy)).add(mastodonUser) } } else { if (self.bookmarkedBy ?? Set()).contains(mastodonUser) { diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index b3abfd84..448d81a5 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -183,6 +183,7 @@ DB9D6C2E25E504AC0051B173 /* Attachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9D6C2D25E504AC0051B173 /* Attachment.swift */; }; DB9D6C3825E508BE0051B173 /* Attachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9D6C3725E508BE0051B173 /* Attachment.swift */; }; DBBE1B4525F3474B0081417A /* MastodonPickServerAppearance.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBBE1B4425F3474B0081417A /* MastodonPickServerAppearance.swift */; }; + DBCCC71E25F73297007E1AB6 /* APIService+Reblog.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBCCC71D25F73297007E1AB6 /* APIService+Reblog.swift */; }; DBD9149025DF6D8D00903DFD /* APIService+Onboarding.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBD9148F25DF6D8D00903DFD /* APIService+Onboarding.swift */; }; DBE0821525CD382600FD6BBD /* MastodonRegisterViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE0821425CD382600FD6BBD /* MastodonRegisterViewController.swift */; }; DBE0822425CD3F1E00FD6BBD /* MastodonRegisterViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE0822325CD3F1E00FD6BBD /* MastodonRegisterViewModel.swift */; }; @@ -425,6 +426,7 @@ DB9D6C2D25E504AC0051B173 /* Attachment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Attachment.swift; sourceTree = ""; }; DB9D6C3725E508BE0051B173 /* Attachment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Attachment.swift; sourceTree = ""; }; DBBE1B4425F3474B0081417A /* MastodonPickServerAppearance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonPickServerAppearance.swift; sourceTree = ""; }; + DBCCC71D25F73297007E1AB6 /* APIService+Reblog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Reblog.swift"; sourceTree = ""; }; DBD9148F25DF6D8D00903DFD /* APIService+Onboarding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Onboarding.swift"; sourceTree = ""; }; DBE0821425CD382600FD6BBD /* MastodonRegisterViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonRegisterViewController.swift; sourceTree = ""; }; DBE0822325CD3F1E00FD6BBD /* MastodonRegisterViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonRegisterViewModel.swift; sourceTree = ""; }; @@ -906,6 +908,7 @@ DB98337E25C9452D00AD9700 /* APIService+APIError.swift */, DBD9148F25DF6D8D00903DFD /* APIService+Onboarding.swift */, 2DF75BA625D10E1000694EC8 /* APIService+Favorite.swift */, + DBCCC71D25F73297007E1AB6 /* APIService+Reblog.swift */, DB98336A25C9420100AD9700 /* APIService+App.swift */, DB98337025C9443200AD9700 /* APIService+Authentication.swift */, DB98339B25C96DE600AD9700 /* APIService+Account.swift */, @@ -1663,6 +1666,7 @@ 2D38F1FE25CD481700561493 /* StatusProvider.swift in Sources */, 0FB3D31E25E534C700AAD544 /* PickServerCategoryCollectionViewCell.swift in Sources */, DB45FB0F25CA87D0005A8AC7 /* AuthenticationService.swift in Sources */, + DBCCC71E25F73297007E1AB6 /* APIService+Reblog.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Mastodon/Diffiable/Section/StatusSection.swift b/Mastodon/Diffiable/Section/StatusSection.swift index 5f9d43ed..3bdfffc5 100644 --- a/Mastodon/Diffiable/Section/StatusSection.swift +++ b/Mastodon/Diffiable/Section/StatusSection.swift @@ -159,7 +159,7 @@ extension StatusSection { // set poll let poll = (toot.reblog ?? toot).poll - StatusSection.configure( + StatusSection.configurePoll( cell: cell, poll: poll, requestUserID: requestUserID, @@ -173,7 +173,7 @@ extension StatusSection { } receiveValue: { change in guard case let .update(object) = change.changeType, let newPoll = object as? Poll else { return } - StatusSection.configure( + StatusSection.configurePoll( cell: cell, poll: newPoll, requestUserID: requestUserID, @@ -185,19 +185,7 @@ extension StatusSection { } // toolbar - let replyCountTitle: String = { - let count = (toot.reblog ?? toot).repliesCount?.intValue ?? 0 - return StatusSection.formattedNumberTitleForActionButton(count) - }() - cell.statusView.actionToolbarContainer.replyButton.setTitle(replyCountTitle, for: .normal) - - let isLike = (toot.reblog ?? toot).favouritedBy.flatMap { $0.contains(where: { $0.id == requestUserID }) } ?? false - let favoriteCountTitle: String = { - let count = (toot.reblog ?? toot).favouritesCount.intValue - return StatusSection.formattedNumberTitleForActionButton(count) - }() - cell.statusView.actionToolbarContainer.starButton.setTitle(favoriteCountTitle, for: .normal) - cell.statusView.actionToolbarContainer.isStarButtonHighlight = isLike + StatusSection.configureActionToolBar(cell: cell, toot: toot, requestUserID: requestUserID) // set date let createdAt = (toot.reblog ?? toot).createdAt @@ -215,20 +203,47 @@ extension StatusSection { // do nothing } receiveValue: { change in 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) - cell.statusView.actionToolbarContainer.starButton.setTitle(favoriteCountTitle, for: .normal) - cell.statusView.actionToolbarContainer.isStarButtonHighlight = isLike - os_log("%{public}s[%{public}ld], %{public}s: like count label for toot %s did update: %ld", (#file as NSString).lastPathComponent, #line, #function, targetToot.id, favoriteCount) + let toot = object as? Toot else { return } + StatusSection.configureActionToolBar(cell: cell, toot: toot, requestUserID: requestUserID) + + os_log("%{public}s[%{public}ld], %{public}s: boost count label for toot %s did update: %ld", (#file as NSString).lastPathComponent, #line, #function, toot.id, toot.reblogsCount.intValue) + os_log("%{public}s[%{public}ld], %{public}s: like count label for toot %s did update: %ld", (#file as NSString).lastPathComponent, #line, #function, toot.id, toot.favouritesCount.intValue) } .store(in: &cell.disposeBag) } - static func configure( + static func configureActionToolBar( + cell: StatusTableViewCell, + toot: Toot, + requestUserID: String + ) { + let toot = toot.reblog ?? toot + + // set reply + let replyCountTitle: String = { + let count = toot.repliesCount?.intValue ?? 0 + return StatusSection.formattedNumberTitleForActionButton(count) + }() + cell.statusView.actionToolbarContainer.replyButton.setTitle(replyCountTitle, for: .normal) + // set boost + let isBoosted = toot.rebloggedBy.flatMap { $0.contains(where: { $0.id == requestUserID }) } ?? false + let boostCountTitle: String = { + let count = toot.reblogsCount.intValue + return StatusSection.formattedNumberTitleForActionButton(count) + }() + cell.statusView.actionToolbarContainer.boostButton.setTitle(boostCountTitle, for: .normal) + cell.statusView.actionToolbarContainer.isBoostButtonHighlight = isBoosted + // set like + let isLike = toot.favouritedBy.flatMap { $0.contains(where: { $0.id == requestUserID }) } ?? false + let favoriteCountTitle: String = { + let count = toot.favouritesCount.intValue + return StatusSection.formattedNumberTitleForActionButton(count) + }() + cell.statusView.actionToolbarContainer.favoriteButton.setTitle(favoriteCountTitle, for: .normal) + cell.statusView.actionToolbarContainer.isFavoriteButtonHighlight = isLike + } + + static func configurePoll( cell: StatusTableViewCell, poll: Poll?, requestUserID: String, diff --git a/Mastodon/Generated/Assets.swift b/Mastodon/Generated/Assets.swift index 32786b40..a0831c9a 100644 --- a/Mastodon/Generated/Assets.swift +++ b/Mastodon/Generated/Assets.swift @@ -74,6 +74,7 @@ internal enum Asset { internal static let lightSuccessGreen = ColorAsset(name: "Colors/lightSuccessGreen") internal static let lightWhite = ColorAsset(name: "Colors/lightWhite") internal static let plusCircleFill = ImageAsset(name: "Colors/plus.circle.fill") + internal static let systemGreen = ColorAsset(name: "Colors/system.green") internal static let systemOrange = ColorAsset(name: "Colors/system.orange") } internal enum Welcome { diff --git a/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift b/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift index cd4e5160..e768ae3c 100644 --- a/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift +++ b/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift @@ -16,6 +16,10 @@ import ActiveLabel // MARK: - ActionToolbarContainerDelegate extension StatusTableViewCellDelegate where Self: StatusProvider { + func statusTableViewCell(_ cell: StatusTableViewCell, actionToolbarContainer: ActionToolbarContainer, boostButtonDidPressed sender: UIButton) { + StatusProviderFacade.responseToStatusBoostAction(provider: self, cell: cell) + } + func statusTableViewCell(_ cell: StatusTableViewCell, actionToolbarContainer: ActionToolbarContainer, likeButtonDidPressed sender: UIButton) { StatusProviderFacade.responseToStatusLikeAction(provider: self, cell: cell) } diff --git a/Mastodon/Protocol/StatusProvider/StatusProvider+UITableViewDelegate.swift b/Mastodon/Protocol/StatusProvider/StatusProvider+UITableViewDelegate.swift index 93f627c0..13bb63f0 100644 --- a/Mastodon/Protocol/StatusProvider/StatusProvider+UITableViewDelegate.swift +++ b/Mastodon/Protocol/StatusProvider/StatusProvider+UITableViewDelegate.swift @@ -17,6 +17,7 @@ extension StatusTableViewCellDelegate where Self: StatusProvider { // } func handleTableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { + // update poll when toot appear let now = Date() var pollID: Mastodon.Entity.Poll.ID? toot(for: cell, indexPath: indexPath) diff --git a/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift b/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift index 89446156..cc558997 100644 --- a/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift +++ b/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift @@ -16,6 +16,7 @@ import ActiveLabel enum StatusProviderFacade { } + extension StatusProviderFacade { static func responseToStatusLikeAction(provider: StatusProvider) { @@ -56,10 +57,9 @@ extension StatusProviderFacade { toot .compactMap { toot -> (NSManagedObjectID, Mastodon.API.Favorites.FavoriteKind)? in - guard let toot = toot else { return nil } + guard let toot = toot?.reblog ?? toot else { return nil } let favoriteKind: Mastodon.API.Favorites.FavoriteKind = { - let targetToot = (toot.reblog ?? toot) - let isLiked = targetToot.favouritedBy.flatMap { $0.contains(where: { $0.id == mastodonUserID }) } ?? false + let isLiked = toot.favouritedBy.flatMap { $0.contains(where: { $0.id == mastodonUserID }) } ?? false return isLiked ? .destroy : .create }() return (toot.objectID, favoriteKind) @@ -120,6 +120,110 @@ extension StatusProviderFacade { } +extension StatusProviderFacade { + + + static func responseToStatusBoostAction(provider: StatusProvider) { + _responseToStatusBoostAction( + provider: provider, + toot: provider.toot() + ) + } + + static func responseToStatusBoostAction(provider: StatusProvider, cell: UITableViewCell) { + _responseToStatusBoostAction( + provider: provider, + toot: provider.toot(for: cell, indexPath: nil) + ) + } + + private static func _responseToStatusBoostAction(provider: StatusProvider, toot: Future) { + // prepare authentication + guard let activeMastodonAuthenticationBox = provider.context.authenticationService.activeMastodonAuthenticationBox.value else { + assertionFailure() + return + } + + // prepare current user infos + guard let _currentMastodonUser = provider.context.authenticationService.activeMastodonAuthentication.value?.user else { + assertionFailure() + return + } + let mastodonUserID = activeMastodonAuthenticationBox.userID + assert(_currentMastodonUser.id == mastodonUserID) + let mastodonUserObjectID = _currentMastodonUser.objectID + + guard let context = provider.context else { return } + + // haptic feedback generator + let generator = UIImpactFeedbackGenerator(style: .light) + let responseFeedbackGenerator = UIImpactFeedbackGenerator(style: .medium) + + toot + .compactMap { toot -> (NSManagedObjectID, Mastodon.API.Status.Reblog.BoostKind)? in + guard let toot = toot?.reblog ?? toot else { return nil } + let boostKind: Mastodon.API.Status.Reblog.BoostKind = { + let isBoosted = toot.rebloggedBy.flatMap { $0.contains(where: { $0.id == mastodonUserID }) } ?? false + return isBoosted ? .undoBoost : .boost + }() + return (toot.objectID, boostKind) + } + .map { tootObjectID, boostKind -> AnyPublisher<(Toot.ID, Mastodon.API.Status.Reblog.BoostKind), Error> in + return context.apiService.boost( + tootObjectID: tootObjectID, + mastodonUserObjectID: mastodonUserObjectID, + boostKind: boostKind + ) + .map { tootID in (tootID, boostKind) } + .eraseToAnyPublisher() + } + .setFailureType(to: Error.self) + .eraseToAnyPublisher() + .switchToLatest() + .receive(on: DispatchQueue.main) + .handleEvents { _ in + generator.prepare() + responseFeedbackGenerator.prepare() + } receiveOutput: { _, boostKind in + generator.impactOccurred() + os_log("%{public}s[%{public}ld], %{public}s: [Boost] update local toot reblog status to: %s", ((#file as NSString).lastPathComponent), #line, #function, boostKind == .boost ? "boost" : "unboost") + } receiveCompletion: { completion in + switch completion { + case .failure: + // TODO: handle error + break + case .finished: + break + } + } + .map { tootID, boostKind in + return context.apiService.boost( + statusID: tootID, + boostKind: boostKind, + mastodonAuthenticationBox: activeMastodonAuthenticationBox + ) + } + .switchToLatest() + .receive(on: DispatchQueue.main) + .sink { [weak provider] completion in + guard let provider = provider else { return } + if provider.view.window != nil { + responseFeedbackGenerator.impactOccurred() + } + switch completion { + case .failure(let error): + os_log("%{public}s[%{public}ld], %{public}s: [Boost] remote boost request fail: %{public}s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) + case .finished: + os_log("%{public}s[%{public}ld], %{public}s: [Boost] remote boost request success", ((#file as NSString).lastPathComponent), #line, #function) + } + } receiveValue: { response in + // do nothing + } + .store(in: &provider.disposeBag) + } + +} + extension StatusProviderFacade { enum Target { case toot diff --git a/Mastodon/Resources/Assets.xcassets/Colors/system.green.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/system.green.colorset/Contents.json new file mode 100644 index 00000000..8716dcb7 --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Colors/system.green.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.604", + "green" : "0.741", + "red" : "0.475" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift index 13c3afba..fdebd576 100644 --- a/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift +++ b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift @@ -19,6 +19,7 @@ protocol StatusTableViewCellDelegate: class { func statusTableViewCell(_ cell: StatusTableViewCell, statusView: StatusView, contentWarningActionButtonPressed button: UIButton) func statusTableViewCell(_ cell: StatusTableViewCell, mosaicImageViewContainer: MosaicImageViewContainer, didTapContentWarningVisualEffectView visualEffectView: UIVisualEffectView) func statusTableViewCell(_ cell: StatusTableViewCell, mosaicImageViewContainer: MosaicImageViewContainer, didTapImageView imageView: UIImageView, atIndex index: Int) + func statusTableViewCell(_ cell: StatusTableViewCell, actionToolbarContainer: ActionToolbarContainer, boostButtonDidPressed sender: UIButton) func statusTableViewCell(_ cell: StatusTableViewCell, actionToolbarContainer: ActionToolbarContainer, likeButtonDidPressed sender: UIButton) func statusTableViewCell(_ cell: StatusTableViewCell, statusView: StatusView, pollVoteButtonPressed button: UIButton) @@ -207,8 +208,8 @@ extension StatusTableViewCell: ActionToolbarContainerDelegate { func actionToolbarContainer(_ actionToolbarContainer: ActionToolbarContainer, replayButtonDidPressed sender: UIButton) { } - func actionToolbarContainer(_ actionToolbarContainer: ActionToolbarContainer, retootButtonDidPressed sender: UIButton) { - + func actionToolbarContainer(_ actionToolbarContainer: ActionToolbarContainer, boostButtonDidPressed sender: UIButton) { + delegate?.statusTableViewCell(self, actionToolbarContainer: actionToolbarContainer, boostButtonDidPressed: sender) } func actionToolbarContainer(_ actionToolbarContainer: ActionToolbarContainer, starButtonDidPressed sender: UIButton) { delegate?.statusTableViewCell(self, actionToolbarContainer: actionToolbarContainer, likeButtonDidPressed: sender) diff --git a/Mastodon/Scene/Share/View/ToolBar/ActionToolBarContainer.swift b/Mastodon/Scene/Share/View/ToolBar/ActionToolBarContainer.swift index 02f60d51..991592c1 100644 --- a/Mastodon/Scene/Share/View/ToolBar/ActionToolBarContainer.swift +++ b/Mastodon/Scene/Share/View/ToolBar/ActionToolBarContainer.swift @@ -10,7 +10,7 @@ import UIKit protocol ActionToolbarContainerDelegate: class { func actionToolbarContainer(_ actionToolbarContainer: ActionToolbarContainer, replayButtonDidPressed sender: UIButton) - func actionToolbarContainer(_ actionToolbarContainer: ActionToolbarContainer, retootButtonDidPressed sender: UIButton) + func actionToolbarContainer(_ actionToolbarContainer: ActionToolbarContainer, boostButtonDidPressed sender: UIButton) func actionToolbarContainer(_ actionToolbarContainer: ActionToolbarContainer, starButtonDidPressed sender: UIButton) func actionToolbarContainer(_ actionToolbarContainer: ActionToolbarContainer, moreButtonDidPressed sender: UIButton) } @@ -19,12 +19,16 @@ protocol ActionToolbarContainerDelegate: class { final class ActionToolbarContainer: UIView { let replyButton = HitTestExpandedButton() - let retootButton = HitTestExpandedButton() - let starButton = HitTestExpandedButton() + let boostButton = HitTestExpandedButton() + let favoriteButton = HitTestExpandedButton() let moreButton = HitTestExpandedButton() - var isStarButtonHighlight: Bool = false { - didSet { isStarButtonHighlightStateDidChange(to: isStarButtonHighlight) } + var isBoostButtonHighlight: Bool = false { + didSet { isBoostButtonHighlightStateDidChange(to: isBoostButtonHighlight) } + } + + var isFavoriteButtonHighlight: Bool = false { + didSet { isFavoriteButtonHighlightStateDidChange(to: isFavoriteButtonHighlight) } } weak var delegate: ActionToolbarContainerDelegate? @@ -57,8 +61,8 @@ extension ActionToolbarContainer { ]) replyButton.addTarget(self, action: #selector(ActionToolbarContainer.replyButtonDidPressed(_:)), for: .touchUpInside) - retootButton.addTarget(self, action: #selector(ActionToolbarContainer.retootButtonDidPressed(_:)), for: .touchUpInside) - starButton.addTarget(self, action: #selector(ActionToolbarContainer.starButtonDidPressed(_:)), for: .touchUpInside) + boostButton.addTarget(self, action: #selector(ActionToolbarContainer.boostButtonDidPressed(_:)), for: .touchUpInside) + favoriteButton.addTarget(self, action: #selector(ActionToolbarContainer.favoriteButtonDidPressed(_:)), for: .touchUpInside) moreButton.addTarget(self, action: #selector(ActionToolbarContainer.moreButtonDidPressed(_:)), for: .touchUpInside) } @@ -89,7 +93,7 @@ extension ActionToolbarContainer { subview.removeFromSuperview() } - let buttons = [replyButton, retootButton, starButton, moreButton] + let buttons = [replyButton, boostButton, favoriteButton, moreButton] buttons.forEach { button in button.tintColor = Asset.Colors.Button.actionToolbar.color button.titleLabel?.font = .monospacedDigitSystemFont(ofSize: 12, weight: .regular) @@ -109,28 +113,28 @@ extension ActionToolbarContainer { button.contentHorizontalAlignment = .leading } replyButton.setImage(replyImage, for: .normal) - retootButton.setImage(reblogImage, for: .normal) - starButton.setImage(starImage, for: .normal) + boostButton.setImage(reblogImage, for: .normal) + favoriteButton.setImage(starImage, for: .normal) moreButton.setImage(moreImage, for: .normal) container.axis = .horizontal container.distribution = .fill replyButton.translatesAutoresizingMaskIntoConstraints = false - retootButton.translatesAutoresizingMaskIntoConstraints = false - starButton.translatesAutoresizingMaskIntoConstraints = false + boostButton.translatesAutoresizingMaskIntoConstraints = false + favoriteButton.translatesAutoresizingMaskIntoConstraints = false moreButton.translatesAutoresizingMaskIntoConstraints = false container.addArrangedSubview(replyButton) - container.addArrangedSubview(retootButton) - container.addArrangedSubview(starButton) + container.addArrangedSubview(boostButton) + container.addArrangedSubview(favoriteButton) container.addArrangedSubview(moreButton) NSLayoutConstraint.activate([ replyButton.heightAnchor.constraint(equalToConstant: 44).priority(.defaultHigh), - replyButton.heightAnchor.constraint(equalTo: retootButton.heightAnchor).priority(.defaultHigh), - replyButton.heightAnchor.constraint(equalTo: starButton.heightAnchor).priority(.defaultHigh), + replyButton.heightAnchor.constraint(equalTo: boostButton.heightAnchor).priority(.defaultHigh), + replyButton.heightAnchor.constraint(equalTo: favoriteButton.heightAnchor).priority(.defaultHigh), replyButton.heightAnchor.constraint(equalTo: moreButton.heightAnchor).priority(.defaultHigh), - replyButton.widthAnchor.constraint(equalTo: retootButton.widthAnchor).priority(.defaultHigh), - replyButton.widthAnchor.constraint(equalTo: starButton.widthAnchor).priority(.defaultHigh), + replyButton.widthAnchor.constraint(equalTo: boostButton.widthAnchor).priority(.defaultHigh), + replyButton.widthAnchor.constraint(equalTo: favoriteButton.widthAnchor).priority(.defaultHigh), ]) moreButton.setContentHuggingPriority(.defaultHigh, for: .horizontal) moreButton.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal) @@ -140,16 +144,16 @@ extension ActionToolbarContainer { button.contentHorizontalAlignment = .center } replyButton.setImage(replyImage, for: .normal) - retootButton.setImage(reblogImage, for: .normal) - starButton.setImage(starImage, for: .normal) + boostButton.setImage(reblogImage, for: .normal) + favoriteButton.setImage(starImage, for: .normal) container.axis = .horizontal container.spacing = 8 container.distribution = .fillEqually container.addArrangedSubview(replyButton) - container.addArrangedSubview(retootButton) - container.addArrangedSubview(starButton) + container.addArrangedSubview(boostButton) + container.addArrangedSubview(favoriteButton) } } @@ -158,11 +162,18 @@ extension ActionToolbarContainer { return oldStyle != style } - private func isStarButtonHighlightStateDidChange(to isHighlight: Bool) { + private func isBoostButtonHighlightStateDidChange(to isHighlight: Bool) { + let tintColor = isHighlight ? Asset.Colors.systemGreen.color : Asset.Colors.Button.actionToolbar.color + boostButton.tintColor = tintColor + boostButton.setTitleColor(tintColor, for: .normal) + boostButton.setTitleColor(tintColor, for: .highlighted) + } + + private func isFavoriteButtonHighlightStateDidChange(to isHighlight: Bool) { let tintColor = isHighlight ? Asset.Colors.systemOrange.color : Asset.Colors.Button.actionToolbar.color - starButton.tintColor = tintColor - starButton.setTitleColor(tintColor, for: .normal) - starButton.setTitleColor(tintColor, for: .highlighted) + favoriteButton.tintColor = tintColor + favoriteButton.setTitleColor(tintColor, for: .normal) + favoriteButton.setTitleColor(tintColor, for: .highlighted) } } @@ -173,12 +184,12 @@ extension ActionToolbarContainer { delegate?.actionToolbarContainer(self, replayButtonDidPressed: sender) } - @objc private func retootButtonDidPressed(_ sender: UIButton) { + @objc private func boostButtonDidPressed(_ sender: UIButton) { os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) - delegate?.actionToolbarContainer(self, retootButtonDidPressed: sender) + delegate?.actionToolbarContainer(self, boostButtonDidPressed: sender) } - @objc private func starButtonDidPressed(_ sender: UIButton) { + @objc private func favoriteButtonDidPressed(_ sender: UIButton) { os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) delegate?.actionToolbarContainer(self, starButtonDidPressed: sender) } diff --git a/Mastodon/Service/APIService/APIService+Favorite.swift b/Mastodon/Service/APIService/APIService+Favorite.swift index e1d5febe..af8f0ffa 100644 --- a/Mastodon/Service/APIService/APIService+Favorite.swift +++ b/Mastodon/Service/APIService/APIService+Favorite.swift @@ -78,7 +78,7 @@ extension APIService { }() let _oldToot: Toot? = { let request = Toot.sortedFetchRequest - request.predicate = Toot.predicate(domain: mastodonAuthenticationBox.domain, id: entity.id) + request.predicate = Toot.predicate(domain: mastodonAuthenticationBox.domain, id: statusID) request.returnsObjectsAsFaults = false request.relationshipKeyPathsForPrefetching = [#keyPath(Toot.reblog)] do { @@ -112,7 +112,8 @@ extension APIService { .handleEvents(receiveCompletion: { completion in switch completion { case .failure(let error): - print(error) + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: error:", ((#file as NSString).lastPathComponent), #line, #function) + debugPrint(error) case .finished: break } diff --git a/Mastodon/Service/APIService/APIService+Reblog.swift b/Mastodon/Service/APIService/APIService+Reblog.swift new file mode 100644 index 00000000..ca47ec71 --- /dev/null +++ b/Mastodon/Service/APIService/APIService+Reblog.swift @@ -0,0 +1,169 @@ +// +// APIService+Reblog.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-9. +// + +import Foundation +import Combine +import MastodonSDK +import CoreData +import CoreDataStack +import CommonOSLog + +extension APIService { + + // make local state change only + func boost( + tootObjectID: NSManagedObjectID, + mastodonUserObjectID: NSManagedObjectID, + boostKind: Mastodon.API.Status.Reblog.BoostKind + ) -> AnyPublisher { + var _targetTootID: Toot.ID? + let managedObjectContext = backgroundManagedObjectContext + return managedObjectContext.performChanges { + let toot = managedObjectContext.object(with: tootObjectID) as! Toot + let mastodonUser = managedObjectContext.object(with: mastodonUserObjectID) as! MastodonUser + let targetToot = toot.reblog ?? toot + let targetTootID = targetToot.id + _targetTootID = targetTootID + + targetToot.update(reblogged: boostKind == .boost, mastodonUser: mastodonUser) + + } + .tryMap { result in + switch result { + case .success: + guard let targetTootID = _targetTootID else { + throw APIError.implicit(.badRequest) + } + return targetTootID + + case .failure(let error): + assertionFailure(error.localizedDescription) + throw error + } + } + .eraseToAnyPublisher() + } + + // send boost request to remote + func boost( + statusID: Mastodon.Entity.Status.ID, + boostKind: Mastodon.API.Status.Reblog.BoostKind, + mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox + ) -> AnyPublisher, Error> { + let domain = mastodonAuthenticationBox.domain + let authorization = mastodonAuthenticationBox.userAuthorization + let requestMastodonUserID = mastodonAuthenticationBox.userID + return Mastodon.API.Status.Reblog.boost( + session: session, + domain: domain, + statusID: statusID, + boostKind: boostKind, + authorization: authorization + ) + .map { response -> AnyPublisher, Error> in + let log = OSLog.api + let entity = response.value + let managedObjectContext = self.backgroundManagedObjectContext + + return managedObjectContext.performChanges { + let _requestMastodonUser: MastodonUser? = { + let request = MastodonUser.sortedFetchRequest + request.predicate = MastodonUser.predicate(domain: mastodonAuthenticationBox.domain, id: requestMastodonUserID) + request.fetchLimit = 1 + request.returnsObjectsAsFaults = false + do { + return try managedObjectContext.fetch(request).first + } catch { + assertionFailure(error.localizedDescription) + return nil + } + }() + let _oldToot: Toot? = { + let request = Toot.sortedFetchRequest + request.predicate = Toot.predicate(domain: domain, id: statusID) + request.returnsObjectsAsFaults = false + request.relationshipKeyPathsForPrefetching = [#keyPath(Toot.reblog)] + do { + return try managedObjectContext.fetch(request).first + } catch { + assertionFailure(error.localizedDescription) + return nil + } + }() + + guard let requestMastodonUser = _requestMastodonUser, + let oldToot = _oldToot else { + assertionFailure() + return + } + APIService.CoreData.merge(toot: oldToot, entity: entity.reblog ?? entity, requestMastodonUser: requestMastodonUser, domain: mastodonAuthenticationBox.domain, networkDate: response.networkDate) + os_log(.info, log: log, "%{public}s[%{public}ld], %{public}s: did update toot %{public}s reblog status to: %{public}s. now %ld boosts", ((#file as NSString).lastPathComponent), #line, #function, entity.id, entity.reblogged.flatMap { $0 ? "boost" : "unboost" } ?? "", entity.reblogsCount ) + } + .setFailureType(to: Error.self) + .tryMap { result -> Mastodon.Response.Content in + switch result { + case .success: + return response + case .failure(let error): + throw error + } + } + .eraseToAnyPublisher() + } + .switchToLatest() + .handleEvents(receiveCompletion: { completion in + switch completion { + case .failure(let error): + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: error:", ((#file as NSString).lastPathComponent), #line, #function) + debugPrint(error) + case .finished: + break + } + }) + .eraseToAnyPublisher() + } + +} + +extension APIService { +// func likeList( +// limit: Int = onceRequestTootMaxCount, +// userID: String, +// maxID: String? = nil, +// mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox +// ) -> AnyPublisher, Error> { +// +// let requestMastodonUserID = mastodonAuthenticationBox.userID +// let query = Mastodon.API.Favorites.ListQuery(limit: limit, minID: nil, maxID: maxID) +// return Mastodon.API.Favorites.favoritedStatus(domain: mastodonAuthenticationBox.domain, session: session, authorization: mastodonAuthenticationBox.userAuthorization, query: query) +// .map { response -> AnyPublisher, Error> in +// let log = OSLog.api +// +// return APIService.Persist.persistTimeline( +// managedObjectContext: self.backgroundManagedObjectContext, +// domain: mastodonAuthenticationBox.domain, +// query: query, +// response: response, +// persistType: .likeList, +// requestMastodonUserID: requestMastodonUserID, +// log: log +// ) +// .setFailureType(to: Error.self) +// .tryMap { result -> Mastodon.Response.Content<[Mastodon.Entity.Status]> in +// switch result { +// case .success: +// return response +// case .failure(let error): +// throw error +// } +// } +// .eraseToAnyPublisher() +// } +// .switchToLatest() +// .eraseToAnyPublisher() +// } +} diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Favorites.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Favorites.swift index ce77a51d..3b01c2c1 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Favorites.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Favorites.swift @@ -114,14 +114,14 @@ extension Mastodon.API.Favorites { } -public extension Mastodon.API.Favorites { +extension Mastodon.API.Favorites { - enum FavoriteKind { + public enum FavoriteKind { case create case destroy } - struct ListQuery: GetQuery,TimelineQueryType { + public struct ListQuery: GetQuery,TimelineQueryType { public var limit: Int? public var minID: String? diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Status+Reblog.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Status+Reblog.swift new file mode 100644 index 00000000..bc898de9 --- /dev/null +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Status+Reblog.swift @@ -0,0 +1,186 @@ +// +// Mastodon+API+Status+Reblog.swift +// +// +// Created by MainasuK Cirno on 2021-3-9. +// + +import Foundation +import Combine + +extension Mastodon.API.Status.Reblog { + + static func boostedByEndpointURL(domain: String, statusID: Mastodon.Entity.Status.ID) -> URL { + let pathComponent = "statuses/" + statusID + "/reblogged_by" + return Mastodon.API.endpointURL(domain: domain).appendingPathComponent(pathComponent) + } + + /// Boosted by + /// + /// View who boosted a given status. + /// + /// - Since: 0.0.0 + /// - Version: 3.3.0 + /// # Last Update + /// 2021/3/9 + /// # 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 poll( + session: URLSession, + domain: String, + statusID: Mastodon.Entity.Status.ID, + authorization: Mastodon.API.OAuth.Authorization? + ) -> AnyPublisher, Error> { + let request = Mastodon.API.get( + url: boostedByEndpointURL(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() + } + +} + +extension Mastodon.API.Status.Reblog { + + static func reblogEndpointURL(domain: String, statusID: Mastodon.Entity.Status.ID) -> URL { + let pathComponent = "statuses/" + statusID + "/reblog" + return Mastodon.API.endpointURL(domain: domain).appendingPathComponent(pathComponent) + } + + /// Boost + /// + /// Reshare a status. + /// + /// - Since: 0.0.0 + /// - Version: 3.3.0 + /// # Last Update + /// 2021/3/9 + /// # 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. + /// - Returns: `AnyPublisher` contains `Status` nested in the response + public static func boost( + session: URLSession, + domain: String, + statusID: Mastodon.Entity.Status.ID, + query: BoostQuery, + authorization: Mastodon.API.OAuth.Authorization + ) -> AnyPublisher, Error> { + let request = Mastodon.API.post( + url: reblogEndpointURL(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() + } + + public typealias Visibility = Mastodon.Entity.Source.Privacy + + public struct BoostQuery: Codable, PostQuery { + public let visibility: Visibility + + public init(visibility: Visibility) { + self.visibility = visibility + } + } + +} + +extension Mastodon.API.Status.Reblog { + + static func unreblogEndpointURL(domain: String, statusID: Mastodon.Entity.Status.ID) -> URL { + let pathComponent = "statuses/" + statusID + "/unreblog" + return Mastodon.API.endpointURL(domain: domain).appendingPathComponent(pathComponent) + } + + /// Undo boost + /// + /// Undo a reshare of a status. + /// + /// - Since: 0.0.0 + /// - Version: 3.3.0 + /// # Last Update + /// 2021/3/9 + /// # 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. + /// - Returns: `AnyPublisher` contains `Status` nested in the response + public static func undoBoost( + session: URLSession, + domain: String, + statusID: Mastodon.Entity.Status.ID, + authorization: Mastodon.API.OAuth.Authorization + ) -> AnyPublisher, Error> { + let request = Mastodon.API.post( + url: unreblogEndpointURL(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() + } + +} + +extension Mastodon.API.Status.Reblog { + + public enum BoostKind { + case boost + case undoBoost + } + + public static func boost( + session: URLSession, + domain: String, + statusID: Mastodon.Entity.Status.ID, + boostKind: BoostKind, + authorization: Mastodon.API.OAuth.Authorization + ) -> AnyPublisher, Error> { + let url: URL + switch boostKind { + case .boost: url = reblogEndpointURL(domain: domain, statusID: statusID) + case .undoBoost: url = unreblogEndpointURL(domain: domain, statusID: statusID) + } + let request = Mastodon.API.post( + url: url, + 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+Status.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Status.swift new file mode 100644 index 00000000..fd3d9627 --- /dev/null +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Status.swift @@ -0,0 +1,12 @@ +// +// Mastodon+API+Status.swift +// +// +// Created by MainasuK Cirno on 2021-3-9. +// + +import Foundation + +extension Mastodon.API.Status { + public enum Reblog { } +} diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift index 5a55ee10..8dbb2c3c 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 Status { } public enum Timeline { } public enum Favorites { } } From 51b6455c3783cce7b17827dbc8dd1c07b1abdfa3 Mon Sep 17 00:00:00 2001 From: CMK Date: Tue, 9 Mar 2021 19:39:44 +0800 Subject: [PATCH 02/20] chore: rename reblog API --- .../Protocol/StatusProvider/StatusProviderFacade.swift | 6 +++--- Mastodon/Service/APIService/APIService+Reblog.swift | 6 +++--- ...+API+Status+Reblog.swift => Mastodon+API+Reblog.swift} | 8 ++++---- .../Sources/MastodonSDK/API/Mastodon+API+Status.swift | 4 +--- MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift | 1 + 5 files changed, 12 insertions(+), 13 deletions(-) rename MastodonSDK/Sources/MastodonSDK/API/{Mastodon+API+Status+Reblog.swift => Mastodon+API+Reblog.swift} (97%) diff --git a/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift b/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift index cc558997..9d1e3321 100644 --- a/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift +++ b/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift @@ -160,15 +160,15 @@ extension StatusProviderFacade { let responseFeedbackGenerator = UIImpactFeedbackGenerator(style: .medium) toot - .compactMap { toot -> (NSManagedObjectID, Mastodon.API.Status.Reblog.BoostKind)? in + .compactMap { toot -> (NSManagedObjectID, Mastodon.API.Reblog.BoostKind)? in guard let toot = toot?.reblog ?? toot else { return nil } - let boostKind: Mastodon.API.Status.Reblog.BoostKind = { + let boostKind: Mastodon.API.Reblog.BoostKind = { let isBoosted = toot.rebloggedBy.flatMap { $0.contains(where: { $0.id == mastodonUserID }) } ?? false return isBoosted ? .undoBoost : .boost }() return (toot.objectID, boostKind) } - .map { tootObjectID, boostKind -> AnyPublisher<(Toot.ID, Mastodon.API.Status.Reblog.BoostKind), Error> in + .map { tootObjectID, boostKind -> AnyPublisher<(Toot.ID, Mastodon.API.Reblog.BoostKind), Error> in return context.apiService.boost( tootObjectID: tootObjectID, mastodonUserObjectID: mastodonUserObjectID, diff --git a/Mastodon/Service/APIService/APIService+Reblog.swift b/Mastodon/Service/APIService/APIService+Reblog.swift index ca47ec71..796a7817 100644 --- a/Mastodon/Service/APIService/APIService+Reblog.swift +++ b/Mastodon/Service/APIService/APIService+Reblog.swift @@ -18,7 +18,7 @@ extension APIService { func boost( tootObjectID: NSManagedObjectID, mastodonUserObjectID: NSManagedObjectID, - boostKind: Mastodon.API.Status.Reblog.BoostKind + boostKind: Mastodon.API.Reblog.BoostKind ) -> AnyPublisher { var _targetTootID: Toot.ID? let managedObjectContext = backgroundManagedObjectContext @@ -51,13 +51,13 @@ extension APIService { // send boost request to remote func boost( statusID: Mastodon.Entity.Status.ID, - boostKind: Mastodon.API.Status.Reblog.BoostKind, + boostKind: Mastodon.API.Reblog.BoostKind, mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox ) -> AnyPublisher, Error> { let domain = mastodonAuthenticationBox.domain let authorization = mastodonAuthenticationBox.userAuthorization let requestMastodonUserID = mastodonAuthenticationBox.userID - return Mastodon.API.Status.Reblog.boost( + return Mastodon.API.Reblog.boost( session: session, domain: domain, statusID: statusID, diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Status+Reblog.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Reblog.swift similarity index 97% rename from MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Status+Reblog.swift rename to MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Reblog.swift index bc898de9..862028d3 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Status+Reblog.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Reblog.swift @@ -8,7 +8,7 @@ import Foundation import Combine -extension Mastodon.API.Status.Reblog { +extension Mastodon.API.Reblog { static func boostedByEndpointURL(domain: String, statusID: Mastodon.Entity.Status.ID) -> URL { let pathComponent = "statuses/" + statusID + "/reblogged_by" @@ -52,7 +52,7 @@ extension Mastodon.API.Status.Reblog { } -extension Mastodon.API.Status.Reblog { +extension Mastodon.API.Reblog { static func reblogEndpointURL(domain: String, statusID: Mastodon.Entity.Status.ID) -> URL { let pathComponent = "statuses/" + statusID + "/reblog" @@ -107,7 +107,7 @@ extension Mastodon.API.Status.Reblog { } -extension Mastodon.API.Status.Reblog { +extension Mastodon.API.Reblog { static func unreblogEndpointURL(domain: String, statusID: Mastodon.Entity.Status.ID) -> URL { let pathComponent = "statuses/" + statusID + "/unreblog" @@ -151,7 +151,7 @@ extension Mastodon.API.Status.Reblog { } -extension Mastodon.API.Status.Reblog { +extension Mastodon.API.Reblog { public enum BoostKind { case boost diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Status.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Status.swift index fd3d9627..09dd07c4 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Status.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Status.swift @@ -7,6 +7,4 @@ import Foundation -extension Mastodon.API.Status { - public enum Reblog { } -} +extension Mastodon.API.Status { } diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift index 8dbb2c3c..8431287f 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 Reblog { } public enum Status { } public enum Timeline { } public enum Favorites { } From 81c22fee24dfdfb303579f10392f6e3fed7385e0 Mon Sep 17 00:00:00 2001 From: CMK Date: Tue, 9 Mar 2021 19:41:18 +0800 Subject: [PATCH 03/20] fix: count not change after undo boost and undo favorite issue --- Mastodon/Service/APIService/APIService+Favorite.swift | 3 +++ Mastodon/Service/APIService/APIService+Reblog.swift | 3 +++ 2 files changed, 6 insertions(+) diff --git a/Mastodon/Service/APIService/APIService+Favorite.swift b/Mastodon/Service/APIService/APIService+Favorite.swift index af8f0ffa..98b00cf1 100644 --- a/Mastodon/Service/APIService/APIService+Favorite.swift +++ b/Mastodon/Service/APIService/APIService+Favorite.swift @@ -95,6 +95,9 @@ extension APIService { return } APIService.CoreData.merge(toot: oldToot, entity: entity, requestMastodonUser: requestMastodonUser, domain: mastodonAuthenticationBox.domain, networkDate: response.networkDate) + if favoriteKind == .destroy { + oldToot.update(favouritesCount: NSNumber(value: max(0, oldToot.favouritesCount.intValue - 1))) + } os_log(.info, log: log, "%{public}s[%{public}ld], %{public}s: did update toot %{public}s like status to: %{public}s. now %ld likes", ((#file as NSString).lastPathComponent), #line, #function, entity.id, entity.favourited.flatMap { $0 ? "like" : "unlike" } ?? "", entity.favouritesCount ) } .setFailureType(to: Error.self) diff --git a/Mastodon/Service/APIService/APIService+Reblog.swift b/Mastodon/Service/APIService/APIService+Reblog.swift index 796a7817..cfec5c7f 100644 --- a/Mastodon/Service/APIService/APIService+Reblog.swift +++ b/Mastodon/Service/APIService/APIService+Reblog.swift @@ -101,6 +101,9 @@ extension APIService { return } APIService.CoreData.merge(toot: oldToot, entity: entity.reblog ?? entity, requestMastodonUser: requestMastodonUser, domain: mastodonAuthenticationBox.domain, networkDate: response.networkDate) + if boostKind == .undoBoost { + oldToot.update(reblogsCount: NSNumber(value: max(0, oldToot.reblogsCount.intValue - 1))) + } os_log(.info, log: log, "%{public}s[%{public}ld], %{public}s: did update toot %{public}s reblog status to: %{public}s. now %ld boosts", ((#file as NSString).lastPathComponent), #line, #function, entity.id, entity.reblogged.flatMap { $0 ? "boost" : "unboost" } ?? "", entity.reblogsCount ) } .setFailureType(to: Error.self) From 1256ef1d8e3a2d43c6c64371790868a6316a467c Mon Sep 17 00:00:00 2001 From: CMK Date: Wed, 10 Mar 2021 13:36:01 +0800 Subject: [PATCH 04/20] feat: implement boost toot. Add stacked style avatar --- Mastodon.xcodeproj/project.pbxproj | 4 + .../Diffiable/Section/StatusSection.swift | 14 +- .../Protocol/AvatarConfigurableView.swift | 13 +- ...meTimelineViewController+DebugAction.swift | 49 +++++ .../Container/AvatarStackContainerView.swift | 193 ++++++++++++++++++ .../Scene/Share/View/Content/StatusView.swift | 55 +++++ 6 files changed, 323 insertions(+), 5 deletions(-) create mode 100644 Mastodon/Scene/Share/View/Container/AvatarStackContainerView.swift diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 081fb06e..d6d3afb0 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -149,6 +149,7 @@ DB68A05D25E9055900CFDF14 /* Settings.bundle in Resources */ = {isa = PBXBuildFile; fileRef = DB68A05C25E9055900CFDF14 /* Settings.bundle */; }; DB68A06325E905E000CFDF14 /* UIApplication.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB68A06225E905E000CFDF14 /* UIApplication.swift */; }; DB6C8C0F25F0A6AE00AAA452 /* Mastodon+Entity+Error.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6C8C0E25F0A6AE00AAA452 /* Mastodon+Entity+Error.swift */; }; + DB71FD2C25F86A5100512AE1 /* AvatarStackContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB71FD2B25F86A5100512AE1 /* AvatarStackContainerView.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 */; }; @@ -396,6 +397,7 @@ DB68A05C25E9055900CFDF14 /* Settings.bundle */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.plug-in"; path = Settings.bundle; sourceTree = ""; }; DB68A06225E905E000CFDF14 /* UIApplication.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UIApplication.swift; sourceTree = ""; }; DB6C8C0E25F0A6AE00AAA452 /* Mastodon+Entity+Error.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Mastodon+Entity+Error.swift"; sourceTree = ""; }; + DB71FD2B25F86A5100512AE1 /* AvatarStackContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AvatarStackContainerView.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; }; @@ -1168,6 +1170,7 @@ children = ( DB9D6C0D25E4F9780051B173 /* MosaicImageViewContainer.swift */, 2D206B7125F5D27F00143C56 /* AudioContainerView.swift */, + DB71FD2B25F86A5100512AE1 /* AvatarStackContainerView.swift */, ); path = Container; sourceTree = ""; @@ -1684,6 +1687,7 @@ DB5086AB25CC0BBB00C2C187 /* AvatarConfigurableView.swift in Sources */, DB0140AE25C40C7300F9F3CF /* MastodonPinBasedAuthenticationViewModel.swift in Sources */, 2D32EAAC25CB96DC00C9ED86 /* TimelineMiddleLoaderTableViewCell.swift in Sources */, + DB71FD2C25F86A5100512AE1 /* AvatarStackContainerView.swift in Sources */, 2D5A3D6225CFD9CB002347D6 /* HomeTimelineViewController+DebugAction.swift in Sources */, DB9D6BFF25E4F5940051B173 /* ProfileViewController.swift in Sources */, 0FB3D30825E524C600AAD544 /* PickServerCategoriesCell.swift in Sources */, diff --git a/Mastodon/Diffiable/Section/StatusSection.swift b/Mastodon/Diffiable/Section/StatusSection.swift index 216d778a..9d4fcad9 100644 --- a/Mastodon/Diffiable/Section/StatusSection.swift +++ b/Mastodon/Diffiable/Section/StatusSection.swift @@ -86,13 +86,23 @@ extension StatusSection { return L10n.Common.Controls.Status.userBoosted(name) }() - // set name username avatar + // set name username cell.statusView.nameLabel.text = { let author = (toot.reblog ?? toot).author return author.displayName.isEmpty ? author.username : author.displayName }() cell.statusView.usernameLabel.text = "@" + (toot.reblog ?? toot).author.acct - cell.statusView.configure(with: AvatarConfigurableViewConfiguration(avatarImageURL: (toot.reblog ?? toot).author.avatarImageURL())) + // set avatar + if let reblog = toot.reblog { + cell.statusView.avatarButton.isHidden = true + cell.statusView.avatarStackedContainerButton.isHidden = false + cell.statusView.avatarStackedContainerButton.topLeadingAvatarStackedImageView.configure(with: AvatarConfigurableViewConfiguration(avatarImageURL: reblog.author.avatarImageURL())) + cell.statusView.avatarStackedContainerButton.bottomTrailingAvatarStackedImageView.configure(with: AvatarConfigurableViewConfiguration(avatarImageURL: toot.author.avatarImageURL())) + } else { + cell.statusView.avatarButton.isHidden = false + cell.statusView.avatarStackedContainerButton.isHidden = true + cell.statusView.configure(with: AvatarConfigurableViewConfiguration(avatarImageURL: (toot.reblog ?? toot).author.avatarImageURL())) + } // set text cell.statusView.activeTextLabel.config(content: (toot.reblog ?? toot).content) diff --git a/Mastodon/Protocol/AvatarConfigurableView.swift b/Mastodon/Protocol/AvatarConfigurableView.swift index 6c51d576..6391066e 100644 --- a/Mastodon/Protocol/AvatarConfigurableView.swift +++ b/Mastodon/Protocol/AvatarConfigurableView.swift @@ -23,7 +23,13 @@ extension AvatarConfigurableView { public func configure(with configuration: AvatarConfigurableViewConfiguration) { let placeholderImage: UIImage = { let placeholderImage = configuration.placeholderImage ?? UIImage.placeholder(size: Self.configurableAvatarImageSize, color: .systemFill) - return placeholderImage.af.imageRoundedIntoCircle() + if Self.configurableAvatarImageCornerRadius < Self.configurableAvatarImageSize.width * 0.5 { + return placeholderImage + .af.imageAspectScaled(toFill: Self.configurableAvatarImageSize) + .af.imageRounded(withCornerRadius: 4, divideRadiusByImageScale: true) + } else { + return placeholderImage.af.imageRoundedIntoCircle() + } }() // cancel previous task @@ -65,7 +71,8 @@ extension AvatarConfigurableView { ) avatarImageView.layer.masksToBounds = true avatarImageView.layer.cornerRadius = Self.configurableAvatarImageCornerRadius - avatarImageView.layer.cornerCurve = .circular + avatarImageView.layer.cornerCurve = Self.configurableAvatarImageCornerRadius < Self.configurableAvatarImageSize.width * 0.5 ? .continuous :.circular + default: let filter = ScaledToSizeWithRoundedCornersFilter(size: Self.configurableAvatarImageSize, radius: Self.configurableAvatarImageCornerRadius) avatarImageView.af.setImage( @@ -92,7 +99,7 @@ extension AvatarConfigurableView { ) avatarButton.layer.masksToBounds = true avatarButton.layer.cornerRadius = Self.configurableAvatarImageCornerRadius - avatarButton.layer.cornerCurve = .continuous + avatarButton.layer.cornerCurve = Self.configurableAvatarImageCornerRadius < Self.configurableAvatarImageSize.width * 0.5 ? .continuous : .circular default: let filter = ScaledToSizeWithRoundedCornersFilter(size: Self.configurableAvatarImageSize, radius: Self.configurableAvatarImageCornerRadius) avatarButton.af.setImage( diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift index 0937e1fb..9f2b4e72 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift @@ -45,10 +45,18 @@ extension HomeTimelineViewController { guard let self = self else { return } self.moveToTopGapAction(action) }), + UIAction(title: "First Reblog Toot", image: nil, attributes: [], handler: { [weak self] action in + guard let self = self else { return } + self.moveToFirstReblogToot(action) + }), UIAction(title: "First Poll Toot", image: nil, attributes: [], handler: { [weak self] action in guard let self = self else { return } self.moveToFirstPollToot(action) }), + UIAction(title: "First Audio Toot", image: nil, attributes: [], handler: { [weak self] action in + guard let self = self else { return } + self.moveToFirstAudioToot(action) + }), // UIAction(title: "First Reply Toot", image: nil, attributes: [], handler: { [weak self] action in // guard let self = self else { return } // self.moveToFirstReplyToot(action) @@ -101,6 +109,26 @@ extension HomeTimelineViewController { } } + @objc private func moveToFirstReblogToot(_ sender: UIAction) { + guard let diffableDataSource = viewModel.diffableDataSource else { return } + let snapshotTransitioning = diffableDataSource.snapshot() + let item = snapshotTransitioning.itemIdentifiers.first(where: { item in + switch item { + case .homeTimelineIndex(let objectID, _): + let homeTimelineIndex = viewModel.fetchedResultsController.managedObjectContext.object(with: objectID) as! HomeTimelineIndex + return homeTimelineIndex.toot.reblog != nil + default: + return false + } + }) + if let targetItem = item, let index = snapshotTransitioning.indexOfItem(targetItem) { + tableView.scrollToRow(at: IndexPath(row: index, section: 0), at: .middle, animated: true) + tableView.blinkRow(at: IndexPath(row: index, section: 0)) + } else { + print("Not found reblog toot") + } + } + @objc private func moveToFirstPollToot(_ sender: UIAction) { guard let diffableDataSource = viewModel.diffableDataSource else { return } let snapshotTransitioning = diffableDataSource.snapshot() @@ -122,6 +150,27 @@ extension HomeTimelineViewController { } } + @objc private func moveToFirstAudioToot(_ sender: UIAction) { + guard let diffableDataSource = viewModel.diffableDataSource else { return } + let snapshotTransitioning = diffableDataSource.snapshot() + let item = snapshotTransitioning.itemIdentifiers.first(where: { item in + switch item { + case .homeTimelineIndex(let objectID, _): + let homeTimelineIndex = viewModel.fetchedResultsController.managedObjectContext.object(with: objectID) as! HomeTimelineIndex + let toot = homeTimelineIndex.toot.reblog ?? homeTimelineIndex.toot + return toot.mediaAttachments?.contains(where: { $0.type == .audio }) ?? false + default: + return false + } + }) + if let targetItem = item, let index = snapshotTransitioning.indexOfItem(targetItem) { + tableView.scrollToRow(at: IndexPath(row: index, section: 0), at: .middle, animated: true) + tableView.blinkRow(at: IndexPath(row: index, section: 0)) + } else { + print("Not found audio toot") + } + } + @objc private func dropRecentTootsAction(_ sender: UIAction, count: Int) { guard let diffableDataSource = viewModel.diffableDataSource else { return } let snapshotTransitioning = diffableDataSource.snapshot() diff --git a/Mastodon/Scene/Share/View/Container/AvatarStackContainerView.swift b/Mastodon/Scene/Share/View/Container/AvatarStackContainerView.swift new file mode 100644 index 00000000..f2f21605 --- /dev/null +++ b/Mastodon/Scene/Share/View/Container/AvatarStackContainerView.swift @@ -0,0 +1,193 @@ +// +// AvatarStackContainerView.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-10. +// + +import UIKit + +final class AvatarStackedImageView: UIImageView { } + +// MARK: - AvatarConfigurableView +extension AvatarStackedImageView: AvatarConfigurableView { + static var configurableAvatarImageSize: CGSize { CGSize(width: 28, height: 28) } + static var configurableAvatarImageCornerRadius: CGFloat { 4 } + var configurableAvatarImageView: UIImageView? { self } + var configurableAvatarButton: UIButton? { nil } +} + +import os.log +import UIKit + +extension UIControl.State: Hashable { } + +final class AvatarStackContainerButton: UIControl { + + static let containerSize = CGSize(width: 42, height: 42) + static let maskOffset: CGFloat = 2 + + // UIControl.Event - Application: 0x0F000000 + static let primaryAction = UIControl.Event(rawValue: 1 << 25) // 0x01000000 + var primaryActionState: UIControl.State = .normal + + let topLeadingAvatarStackedImageView = AvatarStackedImageView() + let bottomTrailingAvatarStackedImageView = AvatarStackedImageView() + + override init(frame: CGRect) { + super.init(frame: frame) + _init() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } + +} + +extension AvatarStackContainerButton { + + private func _init() { + topLeadingAvatarStackedImageView.translatesAutoresizingMaskIntoConstraints = false + addSubview(topLeadingAvatarStackedImageView) + NSLayoutConstraint.activate([ + topLeadingAvatarStackedImageView.topAnchor.constraint(equalTo: topAnchor), + topLeadingAvatarStackedImageView.leadingAnchor.constraint(equalTo: leadingAnchor), + topLeadingAvatarStackedImageView.widthAnchor.constraint(equalToConstant: AvatarStackedImageView.configurableAvatarImageSize.width).priority(.defaultHigh), + topLeadingAvatarStackedImageView.heightAnchor.constraint(equalToConstant: AvatarStackedImageView.configurableAvatarImageSize.height).priority(.defaultHigh), + ]) + + bottomTrailingAvatarStackedImageView.translatesAutoresizingMaskIntoConstraints = false + addSubview(bottomTrailingAvatarStackedImageView) + NSLayoutConstraint.activate([ + bottomTrailingAvatarStackedImageView.bottomAnchor.constraint(equalTo: bottomAnchor), + bottomTrailingAvatarStackedImageView.trailingAnchor.constraint(equalTo: trailingAnchor), + bottomTrailingAvatarStackedImageView.widthAnchor.constraint(equalToConstant: AvatarStackedImageView.configurableAvatarImageSize.width).priority(.defaultHigh), + bottomTrailingAvatarStackedImageView.heightAnchor.constraint(equalToConstant: AvatarStackedImageView.configurableAvatarImageSize.height).priority(.defaultHigh), + ]) + + // mask topLeadingAvatarStackedImageView + let offset: CGFloat = 2 + let path: CGPath = { + let path = CGMutablePath() + path.addRect(CGRect(origin: .zero, size: AvatarStackContainerButton.containerSize)) + if UIApplication.shared.userInterfaceLayoutDirection == .rightToLeft { + path.addPath(UIBezierPath( + roundedRect: CGRect( + x: AvatarStackedImageView.configurableAvatarImageSize.width + offset, + y: AvatarStackContainerButton.containerSize.height - AvatarStackedImageView.configurableAvatarImageSize.height - offset, + width: AvatarStackedImageView.configurableAvatarImageSize.width, + height: AvatarStackedImageView.configurableAvatarImageSize.height + ), + cornerRadius: AvatarStackedImageView.configurableAvatarImageCornerRadius + ).cgPath) + } else { + path.addPath(UIBezierPath( + roundedRect: CGRect( + x: AvatarStackContainerButton.containerSize.width - AvatarStackedImageView.configurableAvatarImageSize.width - offset, + y: AvatarStackContainerButton.containerSize.height - AvatarStackedImageView.configurableAvatarImageSize.height - offset, + width: AvatarStackedImageView.configurableAvatarImageSize.width, + height: AvatarStackedImageView.configurableAvatarImageSize.height + ), + cornerRadius: AvatarStackedImageView.configurableAvatarImageCornerRadius + ).cgPath) + } + return path + }() + let maskShapeLayer = CAShapeLayer() + maskShapeLayer.backgroundColor = UIColor.black.cgColor + maskShapeLayer.fillRule = .evenOdd + maskShapeLayer.path = path + topLeadingAvatarStackedImageView.layer.mask = maskShapeLayer + + topLeadingAvatarStackedImageView.image = UIImage.placeholder(color: .systemFill) + bottomTrailingAvatarStackedImageView.image = UIImage.placeholder(color: .systemFill) + } + + override var intrinsicContentSize: CGSize { + return AvatarStackContainerButton.containerSize + } + + override func beginTracking(_ touch: UITouch, with event: UIEvent?) -> Bool { + defer { updateAppearance() } + + updateState(touch: touch, event: event) + return super.beginTracking(touch, with: event) + } + + override func continueTracking(_ touch: UITouch, with event: UIEvent?) -> Bool { + defer { updateAppearance() } + + updateState(touch: touch, event: event) + return super.continueTracking(touch, with: event) + } + + override func endTracking(_ touch: UITouch?, with event: UIEvent?) { + defer { updateAppearance() } + resetState() + + if let touch = touch { + if AvatarStackContainerButton.isTouching(touch, view: self, event: event) { + sendActions(for: AvatarStackContainerButton.primaryAction) + } else { + // do nothing + } + } + + super.endTracking(touch, with: event) + } + + override func cancelTracking(with event: UIEvent?) { + defer { updateAppearance() } + + resetState() + super.cancelTracking(with: event) + } + +} + +extension AvatarStackContainerButton { + + private func updateAppearance() { + topLeadingAvatarStackedImageView.alpha = primaryActionState.contains(.highlighted) ? 0.6 : 1.0 + bottomTrailingAvatarStackedImageView.alpha = primaryActionState.contains(.highlighted) ? 0.6 : 1.0 + } + + private static func isTouching(_ touch: UITouch, view: UIView, event: UIEvent?) -> Bool { + let location = touch.location(in: view) + return view.point(inside: location, with: event) + } + + private func resetState() { + primaryActionState = .normal + } + + private func updateState(touch: UITouch, event: UIEvent?) { + primaryActionState = AvatarStackContainerButton.isTouching(touch, view: self, event: event) ? .highlighted : .normal + } + +} + +#if canImport(SwiftUI) && DEBUG +import SwiftUI + +struct AvatarStackContainerButton_Previews: PreviewProvider { + + static var previews: some View { + UIViewPreview(width: 42) { + let avatarStackContainerButton = AvatarStackContainerButton() + avatarStackContainerButton.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + avatarStackContainerButton.widthAnchor.constraint(equalToConstant: 42), + avatarStackContainerButton.heightAnchor.constraint(equalToConstant: 42), + ]) + return avatarStackContainerButton + } + .previewLayout(.fixed(width: 42, height: 42)) + } + +} + +#endif + diff --git a/Mastodon/Scene/Share/View/Content/StatusView.swift b/Mastodon/Scene/Share/View/Content/StatusView.swift index 2713647f..5db0e11e 100644 --- a/Mastodon/Scene/Share/View/Content/StatusView.swift +++ b/Mastodon/Scene/Share/View/Content/StatusView.swift @@ -60,6 +60,7 @@ final class StatusView: UIView { button.setImage(placeholderImage, for: .normal) return button }() + let avatarStackedContainerButton: AvatarStackContainerButton = AvatarStackContainerButton() let nameLabel: UILabel = { let label = UILabel() @@ -238,6 +239,14 @@ extension StatusView { avatarButton.trailingAnchor.constraint(equalTo: avatarView.trailingAnchor), avatarButton.bottomAnchor.constraint(equalTo: avatarView.bottomAnchor), ]) + avatarStackedContainerButton.translatesAutoresizingMaskIntoConstraints = false + avatarView.addSubview(avatarStackedContainerButton) + NSLayoutConstraint.activate([ + avatarStackedContainerButton.topAnchor.constraint(equalTo: avatarView.topAnchor), + avatarStackedContainerButton.leadingAnchor.constraint(equalTo: avatarView.leadingAnchor), + avatarStackedContainerButton.trailingAnchor.constraint(equalTo: avatarView.trailingAnchor), + avatarStackedContainerButton.bottomAnchor.constraint(equalTo: avatarView.bottomAnchor), + ]) // author meta container: [title container | subtitle container] let authorMetaContainerStackView = UIStackView() @@ -360,6 +369,7 @@ extension StatusView { pollStatusStackView.isHidden = true audioView.isHidden = true + avatarStackedContainerButton.isHidden = true contentWarningBlurContentImageView.isHidden = true statusContentWarningContainerStackView.isHidden = true statusContentWarningContainerStackViewBottomLayoutConstraint.isActive = false @@ -429,6 +439,7 @@ import SwiftUI struct StatusView_Previews: PreviewProvider { static let avatarFlora = UIImage(named: "tiraya-adam") + static let avatarMarkus = UIImage(named: "markus-spiske") static var previews: some View { Group { @@ -443,6 +454,49 @@ struct StatusView_Previews: PreviewProvider { return statusView } .previewLayout(.fixed(width: 375, height: 200)) + .previewDisplayName("Normal") + UIViewPreview(width: 375) { + let statusView = StatusView() + statusView.headerContainerStackView.isHidden = false + statusView.avatarButton.isHidden = true + statusView.avatarStackedContainerButton.isHidden = false + statusView.avatarStackedContainerButton.topLeadingAvatarStackedImageView.configure( + with: AvatarConfigurableViewConfiguration( + avatarImageURL: nil, + placeholderImage: avatarFlora + ) + ) + statusView.avatarStackedContainerButton.bottomTrailingAvatarStackedImageView.configure( + with: AvatarConfigurableViewConfiguration( + avatarImageURL: nil, + placeholderImage: avatarMarkus + ) + ) + return statusView + } + .previewLayout(.fixed(width: 375, height: 200)) + .previewDisplayName("Boost") + UIViewPreview(width: 375) { + let statusView = StatusView(frame: CGRect(x: 0, y: 0, width: 375, height: 500)) + statusView.configure( + with: AvatarConfigurableViewConfiguration( + avatarImageURL: nil, + placeholderImage: avatarFlora + ) + ) + statusView.headerContainerStackView.isHidden = false + let images = MosaicImageView_Previews.images + let imageViews = statusView.statusMosaicImageViewContainer.setupImageViews(count: 4, maxHeight: 162) + for (i, imageView) in imageViews.enumerated() { + imageView.image = images[i] + } + statusView.statusMosaicImageViewContainer.isHidden = false + statusView.statusMosaicImageViewContainer.blurVisualEffectView.isHidden = true + statusView.isStatusTextSensitive = false + return statusView + } + .previewLayout(.fixed(width: 375, height: 380)) + .previewDisplayName("Image Meida") UIViewPreview(width: 375) { let statusView = StatusView(frame: CGRect(x: 0, y: 0, width: 375, height: 500)) statusView.configure( @@ -466,6 +520,7 @@ struct StatusView_Previews: PreviewProvider { return statusView } .previewLayout(.fixed(width: 375, height: 380)) + .previewDisplayName("Content Sensitive") } } From cd112c5102ba1ba26ae8f8229e91bc45b6830932 Mon Sep 17 00:00:00 2001 From: CMK Date: Wed, 10 Mar 2021 14:06:08 +0800 Subject: [PATCH 05/20] fix: reblog avatar RTL support issue --- .../Container/AvatarStackContainerView.swift | 33 +++++++------------ 1 file changed, 11 insertions(+), 22 deletions(-) diff --git a/Mastodon/Scene/Share/View/Container/AvatarStackContainerView.swift b/Mastodon/Scene/Share/View/Container/AvatarStackContainerView.swift index f2f21605..361f744b 100644 --- a/Mastodon/Scene/Share/View/Container/AvatarStackContainerView.swift +++ b/Mastodon/Scene/Share/View/Container/AvatarStackContainerView.swift @@ -71,28 +71,17 @@ extension AvatarStackContainerButton { let offset: CGFloat = 2 let path: CGPath = { let path = CGMutablePath() - path.addRect(CGRect(origin: .zero, size: AvatarStackContainerButton.containerSize)) - if UIApplication.shared.userInterfaceLayoutDirection == .rightToLeft { - path.addPath(UIBezierPath( - roundedRect: CGRect( - x: AvatarStackedImageView.configurableAvatarImageSize.width + offset, - y: AvatarStackContainerButton.containerSize.height - AvatarStackedImageView.configurableAvatarImageSize.height - offset, - width: AvatarStackedImageView.configurableAvatarImageSize.width, - height: AvatarStackedImageView.configurableAvatarImageSize.height - ), - cornerRadius: AvatarStackedImageView.configurableAvatarImageCornerRadius - ).cgPath) - } else { - path.addPath(UIBezierPath( - roundedRect: CGRect( - x: AvatarStackContainerButton.containerSize.width - AvatarStackedImageView.configurableAvatarImageSize.width - offset, - y: AvatarStackContainerButton.containerSize.height - AvatarStackedImageView.configurableAvatarImageSize.height - offset, - width: AvatarStackedImageView.configurableAvatarImageSize.width, - height: AvatarStackedImageView.configurableAvatarImageSize.height - ), - cornerRadius: AvatarStackedImageView.configurableAvatarImageCornerRadius - ).cgPath) - } + path.addRect(CGRect(origin: .zero, size: AvatarStackedImageView.configurableAvatarImageSize)) + let mirrorScale: CGFloat = UIApplication.shared.userInterfaceLayoutDirection == .rightToLeft ? -1 : 1 + path.addPath(UIBezierPath( + roundedRect: CGRect( + x: mirrorScale * (AvatarStackContainerButton.containerSize.width - AvatarStackedImageView.configurableAvatarImageSize.width - offset), + y: AvatarStackContainerButton.containerSize.height - AvatarStackedImageView.configurableAvatarImageSize.height - offset, + width: AvatarStackedImageView.configurableAvatarImageSize.width, + height: AvatarStackedImageView.configurableAvatarImageSize.height + ), + cornerRadius: AvatarStackedImageView.configurableAvatarImageCornerRadius + ).cgPath) return path }() let maskShapeLayer = CAShapeLayer() From a5f2bf2334687f766613b1b8b21b3f9d52d07e4c Mon Sep 17 00:00:00 2001 From: CMK Date: Wed, 10 Mar 2021 14:09:38 +0800 Subject: [PATCH 06/20] chore: code cleanup --- .../Container/AvatarStackContainerView.swift | 5 +-- .../APIService/APIService+Reblog.swift | 39 ------------------- 2 files changed, 1 insertion(+), 43 deletions(-) diff --git a/Mastodon/Scene/Share/View/Container/AvatarStackContainerView.swift b/Mastodon/Scene/Share/View/Container/AvatarStackContainerView.swift index 361f744b..ad828d9d 100644 --- a/Mastodon/Scene/Share/View/Container/AvatarStackContainerView.swift +++ b/Mastodon/Scene/Share/View/Container/AvatarStackContainerView.swift @@ -5,8 +5,8 @@ // Created by MainasuK Cirno on 2021-3-10. // +import os.log import UIKit - final class AvatarStackedImageView: UIImageView { } // MARK: - AvatarConfigurableView @@ -17,9 +17,6 @@ extension AvatarStackedImageView: AvatarConfigurableView { var configurableAvatarButton: UIButton? { nil } } -import os.log -import UIKit - extension UIControl.State: Hashable { } final class AvatarStackContainerButton: UIControl { diff --git a/Mastodon/Service/APIService/APIService+Reblog.swift b/Mastodon/Service/APIService/APIService+Reblog.swift index cfec5c7f..55b4699a 100644 --- a/Mastodon/Service/APIService/APIService+Reblog.swift +++ b/Mastodon/Service/APIService/APIService+Reblog.swift @@ -131,42 +131,3 @@ extension APIService { } } - -extension APIService { -// func likeList( -// limit: Int = onceRequestTootMaxCount, -// userID: String, -// maxID: String? = nil, -// mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox -// ) -> AnyPublisher, Error> { -// -// let requestMastodonUserID = mastodonAuthenticationBox.userID -// let query = Mastodon.API.Favorites.ListQuery(limit: limit, minID: nil, maxID: maxID) -// return Mastodon.API.Favorites.favoritedStatus(domain: mastodonAuthenticationBox.domain, session: session, authorization: mastodonAuthenticationBox.userAuthorization, query: query) -// .map { response -> AnyPublisher, Error> in -// let log = OSLog.api -// -// return APIService.Persist.persistTimeline( -// managedObjectContext: self.backgroundManagedObjectContext, -// domain: mastodonAuthenticationBox.domain, -// query: query, -// response: response, -// persistType: .likeList, -// requestMastodonUserID: requestMastodonUserID, -// log: log -// ) -// .setFailureType(to: Error.self) -// .tryMap { result -> Mastodon.Response.Content<[Mastodon.Entity.Status]> in -// switch result { -// case .success: -// return response -// case .failure(let error): -// throw error -// } -// } -// .eraseToAnyPublisher() -// } -// .switchToLatest() -// .eraseToAnyPublisher() -// } -} From 0be862c6b3b129190c18c4c23c4d311ae6cb1bdc Mon Sep 17 00:00:00 2001 From: CMK Date: Thu, 11 Mar 2021 11:19:07 +0800 Subject: [PATCH 07/20] chore: remove useless extension for UIControl.State. Correct AvatarStackContainerButton filename --- Mastodon.xcodeproj/project.pbxproj | 8 ++++---- .../AvatarStackContainerButton.swift} | 4 +--- 2 files changed, 5 insertions(+), 7 deletions(-) rename Mastodon/Scene/Share/View/{Container/AvatarStackContainerView.swift => Control/AvatarStackContainerButton.swift} (98%) diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index d6d3afb0..439b8ba9 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -149,7 +149,7 @@ DB68A05D25E9055900CFDF14 /* Settings.bundle in Resources */ = {isa = PBXBuildFile; fileRef = DB68A05C25E9055900CFDF14 /* Settings.bundle */; }; DB68A06325E905E000CFDF14 /* UIApplication.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB68A06225E905E000CFDF14 /* UIApplication.swift */; }; DB6C8C0F25F0A6AE00AAA452 /* Mastodon+Entity+Error.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6C8C0E25F0A6AE00AAA452 /* Mastodon+Entity+Error.swift */; }; - DB71FD2C25F86A5100512AE1 /* AvatarStackContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB71FD2B25F86A5100512AE1 /* AvatarStackContainerView.swift */; }; + DB71FD2C25F86A5100512AE1 /* AvatarStackContainerButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB71FD2B25F86A5100512AE1 /* AvatarStackContainerButton.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 */; }; @@ -397,7 +397,7 @@ DB68A05C25E9055900CFDF14 /* Settings.bundle */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.plug-in"; path = Settings.bundle; sourceTree = ""; }; DB68A06225E905E000CFDF14 /* UIApplication.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UIApplication.swift; sourceTree = ""; }; DB6C8C0E25F0A6AE00AAA452 /* Mastodon+Entity+Error.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Mastodon+Entity+Error.swift"; sourceTree = ""; }; - DB71FD2B25F86A5100512AE1 /* AvatarStackContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AvatarStackContainerView.swift; sourceTree = ""; }; + DB71FD2B25F86A5100512AE1 /* AvatarStackContainerButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AvatarStackContainerButton.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; }; @@ -1170,7 +1170,6 @@ children = ( DB9D6C0D25E4F9780051B173 /* MosaicImageViewContainer.swift */, 2D206B7125F5D27F00143C56 /* AudioContainerView.swift */, - DB71FD2B25F86A5100512AE1 /* AvatarStackContainerView.swift */, ); path = Container; sourceTree = ""; @@ -1187,6 +1186,7 @@ DBA9B90325F1D4420012E7B6 /* Control */ = { isa = PBXGroup; children = ( + DB71FD2B25F86A5100512AE1 /* AvatarStackContainerButton.swift */, DB59F11725EFA35B001F1DAB /* StripProgressView.swift */, ); path = Control; @@ -1687,7 +1687,7 @@ DB5086AB25CC0BBB00C2C187 /* AvatarConfigurableView.swift in Sources */, DB0140AE25C40C7300F9F3CF /* MastodonPinBasedAuthenticationViewModel.swift in Sources */, 2D32EAAC25CB96DC00C9ED86 /* TimelineMiddleLoaderTableViewCell.swift in Sources */, - DB71FD2C25F86A5100512AE1 /* AvatarStackContainerView.swift in Sources */, + DB71FD2C25F86A5100512AE1 /* AvatarStackContainerButton.swift in Sources */, 2D5A3D6225CFD9CB002347D6 /* HomeTimelineViewController+DebugAction.swift in Sources */, DB9D6BFF25E4F5940051B173 /* ProfileViewController.swift in Sources */, 0FB3D30825E524C600AAD544 /* PickServerCategoriesCell.swift in Sources */, diff --git a/Mastodon/Scene/Share/View/Container/AvatarStackContainerView.swift b/Mastodon/Scene/Share/View/Control/AvatarStackContainerButton.swift similarity index 98% rename from Mastodon/Scene/Share/View/Container/AvatarStackContainerView.swift rename to Mastodon/Scene/Share/View/Control/AvatarStackContainerButton.swift index ad828d9d..1e4bd24f 100644 --- a/Mastodon/Scene/Share/View/Container/AvatarStackContainerView.swift +++ b/Mastodon/Scene/Share/View/Control/AvatarStackContainerButton.swift @@ -1,5 +1,5 @@ // -// AvatarStackContainerView.swift +// AvatarStackContainerButton.swift // Mastodon // // Created by MainasuK Cirno on 2021-3-10. @@ -17,8 +17,6 @@ extension AvatarStackedImageView: AvatarConfigurableView { var configurableAvatarButton: UIButton? { nil } } -extension UIControl.State: Hashable { } - final class AvatarStackContainerButton: UIControl { static let containerSize = CGSize(width: 42, height: 42) From 51ee576c24acfb8bab27b31d4013a4a2d2acd524 Mon Sep 17 00:00:00 2001 From: CMK Date: Thu, 11 Mar 2021 11:36:42 +0800 Subject: [PATCH 08/20] fix: redundant execute path for avatar setting --- Mastodon/Diffiable/Section/StatusSection.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Mastodon/Diffiable/Section/StatusSection.swift b/Mastodon/Diffiable/Section/StatusSection.swift index 9d4fcad9..bd6b805e 100644 --- a/Mastodon/Diffiable/Section/StatusSection.swift +++ b/Mastodon/Diffiable/Section/StatusSection.swift @@ -101,7 +101,7 @@ extension StatusSection { } else { cell.statusView.avatarButton.isHidden = false cell.statusView.avatarStackedContainerButton.isHidden = true - cell.statusView.configure(with: AvatarConfigurableViewConfiguration(avatarImageURL: (toot.reblog ?? toot).author.avatarImageURL())) + cell.statusView.configure(with: AvatarConfigurableViewConfiguration(avatarImageURL: toot.author.avatarImageURL())) } // set text From f4136d03bad16d2afaea1dfb356afd371af22368 Mon Sep 17 00:00:00 2001 From: CMK Date: Thu, 11 Mar 2021 11:43:49 +0800 Subject: [PATCH 09/20] fix: add missing fetchLimit --- Mastodon/Service/APIService/APIService+Favorite.swift | 1 + Mastodon/Service/APIService/APIService+Reblog.swift | 1 + 2 files changed, 2 insertions(+) diff --git a/Mastodon/Service/APIService/APIService+Favorite.swift b/Mastodon/Service/APIService/APIService+Favorite.swift index 98b00cf1..2577b6cb 100644 --- a/Mastodon/Service/APIService/APIService+Favorite.swift +++ b/Mastodon/Service/APIService/APIService+Favorite.swift @@ -79,6 +79,7 @@ extension APIService { let _oldToot: Toot? = { let request = Toot.sortedFetchRequest request.predicate = Toot.predicate(domain: mastodonAuthenticationBox.domain, id: statusID) + request.fetchLimit = 1 request.returnsObjectsAsFaults = false request.relationshipKeyPathsForPrefetching = [#keyPath(Toot.reblog)] do { diff --git a/Mastodon/Service/APIService/APIService+Reblog.swift b/Mastodon/Service/APIService/APIService+Reblog.swift index 55b4699a..5525fe5b 100644 --- a/Mastodon/Service/APIService/APIService+Reblog.swift +++ b/Mastodon/Service/APIService/APIService+Reblog.swift @@ -85,6 +85,7 @@ extension APIService { let _oldToot: Toot? = { let request = Toot.sortedFetchRequest request.predicate = Toot.predicate(domain: domain, id: statusID) + request.fetchLimit = 1 request.returnsObjectsAsFaults = false request.relationshipKeyPathsForPrefetching = [#keyPath(Toot.reblog)] do { From 6b5bb4f178d3e22590fee1a12457db9d8d97f391 Mon Sep 17 00:00:00 2001 From: CMK Date: Thu, 11 Mar 2021 14:08:00 +0800 Subject: [PATCH 10/20] chore: make the database request Fail-Fast --- .../Service/APIService/APIService+Reblog.swift | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/Mastodon/Service/APIService/APIService+Reblog.swift b/Mastodon/Service/APIService/APIService+Reblog.swift index 5525fe5b..a0268b34 100644 --- a/Mastodon/Service/APIService/APIService+Reblog.swift +++ b/Mastodon/Service/APIService/APIService+Reblog.swift @@ -70,7 +70,7 @@ extension APIService { let managedObjectContext = self.backgroundManagedObjectContext return managedObjectContext.performChanges { - let _requestMastodonUser: MastodonUser? = { + guard let requestMastodonUser: MastodonUser = { let request = MastodonUser.sortedFetchRequest request.predicate = MastodonUser.predicate(domain: mastodonAuthenticationBox.domain, id: requestMastodonUserID) request.fetchLimit = 1 @@ -81,8 +81,11 @@ extension APIService { assertionFailure(error.localizedDescription) return nil } - }() - let _oldToot: Toot? = { + }() else { + return + } + + guard let oldToot: Toot = { let request = Toot.sortedFetchRequest request.predicate = Toot.predicate(domain: domain, id: statusID) request.fetchLimit = 1 @@ -94,13 +97,10 @@ extension APIService { assertionFailure(error.localizedDescription) return nil } - }() - - guard let requestMastodonUser = _requestMastodonUser, - let oldToot = _oldToot else { - assertionFailure() + }() else { return } + APIService.CoreData.merge(toot: oldToot, entity: entity.reblog ?? entity, requestMastodonUser: requestMastodonUser, domain: mastodonAuthenticationBox.domain, networkDate: response.networkDate) if boostKind == .undoBoost { oldToot.update(reblogsCount: NSNumber(value: max(0, oldToot.reblogsCount.intValue - 1))) From 71c5ca327ae4caa9d3095b62383b698b3abd83f6 Mon Sep 17 00:00:00 2001 From: CMK Date: Thu, 11 Mar 2021 14:34:10 +0800 Subject: [PATCH 11/20] chore: make fetch free from exception --- Mastodon.xcodeproj/project.pbxproj | 4 ++++ .../Extension/NSManagedObjectContext.swift | 20 +++++++++++++++++++ .../APIService/APIService+Reblog.swift | 14 ++----------- 3 files changed, 26 insertions(+), 12 deletions(-) create mode 100644 Mastodon/Extension/NSManagedObjectContext.swift diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 439b8ba9..8bb813fe 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -136,6 +136,7 @@ DB45FAF925CA80A2005A8AC7 /* APIService+CoreData+MastodonAuthentication.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB45FAF825CA80A2005A8AC7 /* APIService+CoreData+MastodonAuthentication.swift */; }; DB45FB0F25CA87D0005A8AC7 /* AuthenticationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB45FB0E25CA87D0005A8AC7 /* AuthenticationService.swift */; }; DB45FB1D25CA9D23005A8AC7 /* APIService+HomeTimeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB45FB1C25CA9D23005A8AC7 /* APIService+HomeTimeline.swift */; }; + DB47229725F9EFAD00DA7F53 /* NSManagedObjectContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB47229625F9EFAD00DA7F53 /* NSManagedObjectContext.swift */; }; DB5086A525CC0B7000C2C187 /* AvatarBarButtonItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB5086A425CC0B7000C2C187 /* AvatarBarButtonItem.swift */; }; DB5086AB25CC0BBB00C2C187 /* AvatarConfigurableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB5086AA25CC0BBB00C2C187 /* AvatarConfigurableView.swift */; }; DB5086B825CC0D6400C2C187 /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = DB5086B725CC0D6400C2C187 /* Kingfisher */; }; @@ -385,6 +386,7 @@ DB45FAF825CA80A2005A8AC7 /* APIService+CoreData+MastodonAuthentication.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+CoreData+MastodonAuthentication.swift"; sourceTree = ""; }; DB45FB0E25CA87D0005A8AC7 /* AuthenticationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthenticationService.swift; sourceTree = ""; }; DB45FB1C25CA9D23005A8AC7 /* APIService+HomeTimeline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+HomeTimeline.swift"; sourceTree = ""; }; + DB47229625F9EFAD00DA7F53 /* NSManagedObjectContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSManagedObjectContext.swift; sourceTree = ""; }; DB5086A425CC0B7000C2C187 /* AvatarBarButtonItem.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AvatarBarButtonItem.swift; sourceTree = ""; }; DB5086AA25CC0BBB00C2C187 /* AvatarConfigurableView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AvatarConfigurableView.swift; sourceTree = ""; }; DB5086BD25CC0D9900C2C187 /* SplashPreference.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SplashPreference.swift; sourceTree = ""; }; @@ -1118,6 +1120,7 @@ 2D206B7F25F5F45E00143C56 /* UIImage.swift */, 2D206B8525F5FB0900143C56 /* Double.swift */, 2D206B9125F60EA700143C56 /* UIControl.swift */, + DB47229625F9EFAD00DA7F53 /* NSManagedObjectContext.swift */, ); path = Extension; sourceTree = ""; @@ -1625,6 +1628,7 @@ 2DF75BA125D0E29D00694EC8 /* StatusProvider+StatusTableViewCellDelegate.swift in Sources */, 2DA6054725F716A2006356F9 /* PlaybackState.swift in Sources */, DB59F10E25EF724F001F1DAB /* APIService+Poll.swift in Sources */, + DB47229725F9EFAD00DA7F53 /* NSManagedObjectContext.swift in Sources */, 2D42FF7E25C82218004A627A /* ActionToolBarContainer.swift in Sources */, DB0140A125C40C0600F9F3CF /* MastodonPinBasedAuthenticationViewController.swift in Sources */, DB1FD43625F26899004CFCFC /* MastodonPickServerViewModel+LoadIndexedServerState.swift in Sources */, diff --git a/Mastodon/Extension/NSManagedObjectContext.swift b/Mastodon/Extension/NSManagedObjectContext.swift new file mode 100644 index 00000000..9c569a8f --- /dev/null +++ b/Mastodon/Extension/NSManagedObjectContext.swift @@ -0,0 +1,20 @@ +// +// NSManagedObjectContext.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-11. +// + +import Foundation +import CoreData + +extension NSManagedObjectContext { + func safeFetch(_ request: NSFetchRequest) -> [T] where T : NSFetchRequestResult { + do { + return try fetch(request) + } catch { + assertionFailure(error.localizedDescription) + return [] + } + } +} diff --git a/Mastodon/Service/APIService/APIService+Reblog.swift b/Mastodon/Service/APIService/APIService+Reblog.swift index a0268b34..3f750954 100644 --- a/Mastodon/Service/APIService/APIService+Reblog.swift +++ b/Mastodon/Service/APIService/APIService+Reblog.swift @@ -75,12 +75,7 @@ extension APIService { request.predicate = MastodonUser.predicate(domain: mastodonAuthenticationBox.domain, id: requestMastodonUserID) request.fetchLimit = 1 request.returnsObjectsAsFaults = false - do { - return try managedObjectContext.fetch(request).first - } catch { - assertionFailure(error.localizedDescription) - return nil - } + return managedObjectContext.safeFetch(request).first }() else { return } @@ -91,12 +86,7 @@ extension APIService { request.fetchLimit = 1 request.returnsObjectsAsFaults = false request.relationshipKeyPathsForPrefetching = [#keyPath(Toot.reblog)] - do { - return try managedObjectContext.fetch(request).first - } catch { - assertionFailure(error.localizedDescription) - return nil - } + return managedObjectContext.safeFetch(request).first }() else { return } From 988723691e2b37346df3d5ca4ca061aa25a7d0a4 Mon Sep 17 00:00:00 2001 From: CMK Date: Mon, 15 Mar 2021 16:28:22 +0800 Subject: [PATCH 12/20] fix: content warning overlay invalid due to cell reuse issue --- Mastodon/Diffiable/Section/StatusSection.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Mastodon/Diffiable/Section/StatusSection.swift b/Mastodon/Diffiable/Section/StatusSection.swift index f6a4b918..8f89cdd3 100644 --- a/Mastodon/Diffiable/Section/StatusSection.swift +++ b/Mastodon/Diffiable/Section/StatusSection.swift @@ -182,6 +182,7 @@ extension StatusSection { let isStatusSensitive = statusItemAttribute.isStatusSensitive cell.statusView.statusMosaicImageViewContainer.contentWarningOverlayView.blurVisualEffectView.effect = isStatusSensitive ? ContentWarningOverlayView.blurVisualEffect : nil cell.statusView.statusMosaicImageViewContainer.contentWarningOverlayView.vibrancyVisualEffectView.alpha = isStatusSensitive ? 1.0 : 0.0 + cell.statusView.statusMosaicImageViewContainer.contentWarningOverlayView.isUserInteractionEnabled = isStatusSensitive // set audio if let audioAttachment = mediaAttachments.filter({ $0.type == .audio }).first { From 6a8dee037f17b764ee63756ae1eeff7e760f11ee Mon Sep 17 00:00:00 2001 From: CMK Date: Mon, 15 Mar 2021 17:52:28 +0800 Subject: [PATCH 13/20] fix: media not response for reblog issue --- .../StatusProvider+UITableViewDelegate.swift | 1 + .../PlayerContainerView+MediaTypeIndicotorView.swift | 8 ++++++++ 2 files changed, 9 insertions(+) create mode 100644 Mastodon/Scene/Share/View/Container/PlayerContainerView+MediaTypeIndicotorView.swift diff --git a/Mastodon/Protocol/StatusProvider/StatusProvider+UITableViewDelegate.swift b/Mastodon/Protocol/StatusProvider/StatusProvider+UITableViewDelegate.swift index 3d7074d3..89ae8e6e 100644 --- a/Mastodon/Protocol/StatusProvider/StatusProvider+UITableViewDelegate.swift +++ b/Mastodon/Protocol/StatusProvider/StatusProvider+UITableViewDelegate.swift @@ -71,6 +71,7 @@ extension StatusTableViewCellDelegate where Self: StatusProvider { toot(for: cell, indexPath: indexPath) .sink { [weak self] toot in guard let self = self else { return } + let toot = toot?.reblog ?? toot guard let media = (toot?.mediaAttachments ?? Set()).first else { return } guard let videoPlayerViewModel = self.context.videoPlaybackService.dequeueVideoPlayerViewModel(for: media) else { return } diff --git a/Mastodon/Scene/Share/View/Container/PlayerContainerView+MediaTypeIndicotorView.swift b/Mastodon/Scene/Share/View/Container/PlayerContainerView+MediaTypeIndicotorView.swift new file mode 100644 index 00000000..0accc40b --- /dev/null +++ b/Mastodon/Scene/Share/View/Container/PlayerContainerView+MediaTypeIndicotorView.swift @@ -0,0 +1,8 @@ +// +// PlayerContainerView+MediaTypeIndicotorView.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-15. +// + +import Foundation From 5b45224f7bfabefb1ea564fc8ed5e48cbc083771 Mon Sep 17 00:00:00 2001 From: CMK Date: Mon, 15 Mar 2021 17:53:06 +0800 Subject: [PATCH 14/20] feat: make media indicator view hide when playing video --- Mastodon.xcodeproj/project.pbxproj | 4 + .../Diffiable/Section/StatusSection.swift | 17 ++++ ...ContainerView+MediaTypeIndicotorView.swift | 93 ++++++++++++++++++- .../View/Container/PlayerContainerView.swift | 79 ++++++---------- 4 files changed, 139 insertions(+), 54 deletions(-) diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index d3bea6e8..9859676c 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -143,6 +143,7 @@ DB45FB0F25CA87D0005A8AC7 /* AuthenticationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB45FB0E25CA87D0005A8AC7 /* AuthenticationService.swift */; }; DB45FB1D25CA9D23005A8AC7 /* APIService+HomeTimeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB45FB1C25CA9D23005A8AC7 /* APIService+HomeTimeline.swift */; }; DB47229725F9EFAD00DA7F53 /* NSManagedObjectContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB47229625F9EFAD00DA7F53 /* NSManagedObjectContext.swift */; }; + DB49A63D25FF609300B98345 /* PlayerContainerView+MediaTypeIndicotorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB49A63C25FF609300B98345 /* PlayerContainerView+MediaTypeIndicotorView.swift */; }; DB5086A525CC0B7000C2C187 /* AvatarBarButtonItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB5086A425CC0B7000C2C187 /* AvatarBarButtonItem.swift */; }; DB5086AB25CC0BBB00C2C187 /* AvatarConfigurableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB5086AA25CC0BBB00C2C187 /* AvatarConfigurableView.swift */; }; DB5086B825CC0D6400C2C187 /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = DB5086B725CC0D6400C2C187 /* Kingfisher */; }; @@ -400,6 +401,7 @@ DB45FB0E25CA87D0005A8AC7 /* AuthenticationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthenticationService.swift; sourceTree = ""; }; DB45FB1C25CA9D23005A8AC7 /* APIService+HomeTimeline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+HomeTimeline.swift"; sourceTree = ""; }; DB47229625F9EFAD00DA7F53 /* NSManagedObjectContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSManagedObjectContext.swift; sourceTree = ""; }; + DB49A63C25FF609300B98345 /* PlayerContainerView+MediaTypeIndicotorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PlayerContainerView+MediaTypeIndicotorView.swift"; sourceTree = ""; }; DB5086A425CC0B7000C2C187 /* AvatarBarButtonItem.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AvatarBarButtonItem.swift; sourceTree = ""; }; DB5086AA25CC0BBB00C2C187 /* AvatarConfigurableView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AvatarConfigurableView.swift; sourceTree = ""; }; DB5086BD25CC0D9900C2C187 /* SplashPreference.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SplashPreference.swift; sourceTree = ""; }; @@ -1190,6 +1192,7 @@ DB9D6C0D25E4F9780051B173 /* MosaicImageViewContainer.swift */, 2D206B7125F5D27F00143C56 /* AudioContainerView.swift */, 5DF1057825F88A1D00D6C0D4 /* PlayerContainerView.swift */, + DB49A63C25FF609300B98345 /* PlayerContainerView+MediaTypeIndicotorView.swift */, 5DF1057E25F88A4100D6C0D4 /* TouchBlockingView.swift */, ); path = Container; @@ -1675,6 +1678,7 @@ DB45FADD25CA6F6B005A8AC7 /* APIService+CoreData+MastodonUser.swift in Sources */, 2D32EABA25CB9B0500C9ED86 /* UIView.swift in Sources */, 2D38F20825CD491300561493 /* DisposeBagCollectable.swift in Sources */, + DB49A63D25FF609300B98345 /* PlayerContainerView+MediaTypeIndicotorView.swift in Sources */, DB0140CF25C42AEE00F9F3CF /* OSLog.swift in Sources */, DB44384F25E8C1FA008912A2 /* CALayer.swift in Sources */, 2D206B8625F5FB0900143C56 /* Double.swift in Sources */, diff --git a/Mastodon/Diffiable/Section/StatusSection.swift b/Mastodon/Diffiable/Section/StatusSection.swift index 8f89cdd3..fad4898d 100644 --- a/Mastodon/Diffiable/Section/StatusSection.swift +++ b/Mastodon/Diffiable/Section/StatusSection.swift @@ -222,6 +222,23 @@ extension StatusSection { playerViewController.player = videoPlayerViewModel.player playerViewController.showsPlaybackControls = videoPlayerViewModel.videoKind != .gif playerContainerView.setMediaKind(kind: videoPlayerViewModel.videoKind) + if videoPlayerViewModel.videoKind == .gif { + playerContainerView.setMediaIndicator(isHidden: false) + } else { + videoPlayerViewModel.timeControlStatus.sink { timeControlStatus in + UIView.animate(withDuration: 0.33) { + switch timeControlStatus { + case .playing: + playerContainerView.setMediaIndicator(isHidden: true) + case .paused, .waitingToPlayAtSpecifiedRate: + playerContainerView.setMediaIndicator(isHidden: false) + @unknown default: + assertionFailure() + } + } + } + .store(in: &cell.disposeBag) + } playerContainerView.isHidden = false } else { diff --git a/Mastodon/Scene/Share/View/Container/PlayerContainerView+MediaTypeIndicotorView.swift b/Mastodon/Scene/Share/View/Container/PlayerContainerView+MediaTypeIndicotorView.swift index 0accc40b..3bff6ef7 100644 --- a/Mastodon/Scene/Share/View/Container/PlayerContainerView+MediaTypeIndicotorView.swift +++ b/Mastodon/Scene/Share/View/Container/PlayerContainerView+MediaTypeIndicotorView.swift @@ -5,4 +5,95 @@ // Created by MainasuK Cirno on 2021-3-15. // -import Foundation +import UIKit + +extension PlayerContainerView { + + final class MediaTypeIndicotorView: UIView { + + static let indicatorViewSize = CGSize(width: 47, height: 25) + + let maskLayer = CAShapeLayer() + + let label: UILabel = { + let label = UILabel() + label.textColor = .white + label.textAlignment = .right + label.adjustsFontSizeToFitWidth = true + label.translatesAutoresizingMaskIntoConstraints = false + return label + }() + + override init(frame: CGRect) { + super.init(frame: frame) + _init() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } + + override func layoutSubviews() { + super.layoutSubviews() + + let path = UIBezierPath() + path.move(to: CGPoint(x: bounds.width, y: bounds.height)) + path.addLine(to: CGPoint(x: bounds.width, y: 0)) + path.addLine(to: CGPoint(x: bounds.width * 0.5, y: 0)) + path.addCurve( + to: CGPoint(x: 0, y: bounds.height), + controlPoint1: CGPoint(x: bounds.width * 0.2, y: 0), + controlPoint2: CGPoint(x: 0, y: bounds.height * 0.3) + ) + path.close() + + maskLayer.frame = bounds + maskLayer.path = path.cgPath + layer.mask = maskLayer + } + } + +} + +extension PlayerContainerView.MediaTypeIndicotorView { + + private func _init() { + backgroundColor = Asset.Colors.Background.mediaTypeIndicotor.color + layoutMargins = UIEdgeInsets(top: 3, left: 13, bottom: 0, right: 6) + + addSubview(label) + NSLayoutConstraint.activate([ + label.topAnchor.constraint(equalTo: layoutMarginsGuide.topAnchor), + label.leadingAnchor.constraint(equalTo: layoutMarginsGuide.leadingAnchor), + label.trailingAnchor.constraint(equalTo: layoutMarginsGuide.trailingAnchor), + label.bottomAnchor.constraint(equalTo: layoutMarginsGuide.bottomAnchor), + ]) + } + + private static func roundedFont(weight: UIFont.Weight,fontSize: CGFloat) -> UIFont { + let systemFont = UIFont.systemFont(ofSize: fontSize, weight: weight) + guard let descriptor = systemFont.fontDescriptor.withDesign(.rounded) else { return systemFont } + let roundedFont = UIFont(descriptor: descriptor, size: fontSize) + return roundedFont + } + + func setMediaKind(kind: VideoPlayerViewModel.Kind) { + let fontSize: CGFloat = 18 + + switch kind { + case .gif: + label.font = PlayerContainerView.MediaTypeIndicotorView.roundedFont(weight: .heavy, fontSize: fontSize) + label.text = "GIF" + case .video: + let configuration = UIImage.SymbolConfiguration(font: PlayerContainerView.MediaTypeIndicotorView.roundedFont(weight: .regular, fontSize: fontSize)) + let image = UIImage(systemName: "video.fill", withConfiguration: configuration)! + let attachment = NSTextAttachment() + attachment.image = image.withTintColor(.white) + label.attributedText = NSAttributedString(attachment: attachment) + } + } + +} + + diff --git a/Mastodon/Scene/Share/View/Container/PlayerContainerView.swift b/Mastodon/Scene/Share/View/Container/PlayerContainerView.swift index 3b4acc98..cabed956 100644 --- a/Mastodon/Scene/Share/View/Container/PlayerContainerView.swift +++ b/Mastodon/Scene/Share/View/Container/PlayerContainerView.swift @@ -26,26 +26,8 @@ final class PlayerContainerView: UIView { let playerViewController = AVPlayerViewController() - let mediaTypeIndicotorLabel: UILabel = { - let label = UILabel() - label.textColor = .white - label.textAlignment = .right - label.translatesAutoresizingMaskIntoConstraints = false - return label - }() - - let mediaTypeIndicotorView: UIView = { - let view = UIView() - view.backgroundColor = Asset.Colors.Background.mediaTypeIndicotor.color - view.translatesAutoresizingMaskIntoConstraints = false - let rect = CGRect(x: 0, y: 0, width: 47, height: 50) - let path = UIBezierPath(roundedRect: rect, byRoundingCorners: [.topLeft], cornerRadii: CGSize(width: 50, height: 50)) - let maskLayer = CAShapeLayer() - maskLayer.frame = rect - maskLayer.path = path.cgPath - view.layer.mask = maskLayer - return view - }() + let mediaTypeIndicotorView = MediaTypeIndicotorView() + let mediaTypeIndicotorViewInContentWarningOverlay = MediaTypeIndicotorView() weak var delegate: PlayerContainerViewDelegate? @@ -78,6 +60,16 @@ extension PlayerContainerView { playerViewController.view.layer.cornerRadius = PlayerContainerView.cornerRadius playerViewController.view.layer.cornerCurve = .continuous + // mediaType + mediaTypeIndicotorView.translatesAutoresizingMaskIntoConstraints = false + playerViewController.contentOverlayView!.addSubview(mediaTypeIndicotorView) + NSLayoutConstraint.activate([ + mediaTypeIndicotorView.bottomAnchor.constraint(equalTo: playerViewController.contentOverlayView!.bottomAnchor), + mediaTypeIndicotorView.trailingAnchor.constraint(equalTo: playerViewController.contentOverlayView!.trailingAnchor), + mediaTypeIndicotorView.heightAnchor.constraint(equalToConstant: 25).priority(.defaultHigh), + mediaTypeIndicotorView.widthAnchor.constraint(equalToConstant: 47).priority(.defaultHigh), + ]) + addSubview(contentWarningOverlayView) NSLayoutConstraint.activate([ contentWarningOverlayView.topAnchor.constraint(equalTo: topAnchor), @@ -87,21 +79,13 @@ extension PlayerContainerView { ]) contentWarningOverlayView.delegate = self - // mediaType - addSubview(mediaTypeIndicotorView) + mediaTypeIndicotorViewInContentWarningOverlay.translatesAutoresizingMaskIntoConstraints = false + contentWarningOverlayView.addSubview(mediaTypeIndicotorViewInContentWarningOverlay) NSLayoutConstraint.activate([ - mediaTypeIndicotorView.bottomAnchor.constraint(equalTo: bottomAnchor), - mediaTypeIndicotorView.trailingAnchor.constraint(equalTo: trailingAnchor), - mediaTypeIndicotorView.heightAnchor.constraint(equalToConstant: 25), - mediaTypeIndicotorView.widthAnchor.constraint(equalToConstant: 47) - ]) - - mediaTypeIndicotorView.addSubview(mediaTypeIndicotorLabel) - NSLayoutConstraint.activate([ - mediaTypeIndicotorLabel.topAnchor.constraint(equalTo: mediaTypeIndicotorView.topAnchor), - mediaTypeIndicotorLabel.leadingAnchor.constraint(equalTo: mediaTypeIndicotorView.leadingAnchor), - mediaTypeIndicotorLabel.bottomAnchor.constraint(equalTo: mediaTypeIndicotorView.bottomAnchor), - mediaTypeIndicotorView.trailingAnchor.constraint(equalTo: mediaTypeIndicotorLabel.trailingAnchor, constant: 8) + mediaTypeIndicotorViewInContentWarningOverlay.bottomAnchor.constraint(equalTo: contentWarningOverlayView.bottomAnchor), + mediaTypeIndicotorViewInContentWarningOverlay.trailingAnchor.constraint(equalTo: contentWarningOverlayView.trailingAnchor), + mediaTypeIndicotorViewInContentWarningOverlay.heightAnchor.constraint(equalToConstant: 25).priority(.defaultHigh), + mediaTypeIndicotorViewInContentWarningOverlay.widthAnchor.constraint(equalToConstant: 47).priority(.defaultHigh), ]) } } @@ -161,25 +145,14 @@ extension PlayerContainerView { return playerViewController } - func roundedFont(weight: UIFont.Weight,fontSize: CGFloat) -> UIFont { - let systemFont = UIFont.systemFont(ofSize: fontSize, weight: weight) - guard let descriptor = systemFont.fontDescriptor.withDesign(.rounded) else { return systemFont } - let roundedFont = UIFont(descriptor: descriptor, size: fontSize) - return roundedFont - } func setMediaKind(kind: VideoPlayerViewModel.Kind) { - let fontSize: CGFloat = 18 - - switch kind { - case .gif: - mediaTypeIndicotorLabel.font = roundedFont(weight: .heavy, fontSize: fontSize) - mediaTypeIndicotorLabel.text = "GIF" - case .video: - let configuration = UIImage.SymbolConfiguration(font: roundedFont(weight: .regular, fontSize: fontSize)) - let image = UIImage(systemName: "video.fill", withConfiguration: configuration)! - let attachment = NSTextAttachment() - attachment.image = image.withTintColor(.white) - mediaTypeIndicotorLabel.attributedText = NSAttributedString(attachment: attachment) - } + mediaTypeIndicotorView.setMediaKind(kind: kind) + mediaTypeIndicotorViewInContentWarningOverlay.setMediaKind(kind: kind) } + + func setMediaIndicator(isHidden: Bool) { + mediaTypeIndicotorView.alpha = isHidden ? 0 : 1 + mediaTypeIndicotorViewInContentWarningOverlay.alpha = isHidden ? 0 : 1 + } + } From b9cfd0d9e8da3b327dc20a967ff10fa2a2ff72fc Mon Sep 17 00:00:00 2001 From: CMK Date: Mon, 15 Mar 2021 17:54:15 +0800 Subject: [PATCH 15/20] chore: remove magic number --- .../Scene/Share/View/Container/PlayerContainerView.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Mastodon/Scene/Share/View/Container/PlayerContainerView.swift b/Mastodon/Scene/Share/View/Container/PlayerContainerView.swift index cabed956..3401bfe9 100644 --- a/Mastodon/Scene/Share/View/Container/PlayerContainerView.swift +++ b/Mastodon/Scene/Share/View/Container/PlayerContainerView.swift @@ -66,8 +66,8 @@ extension PlayerContainerView { NSLayoutConstraint.activate([ mediaTypeIndicotorView.bottomAnchor.constraint(equalTo: playerViewController.contentOverlayView!.bottomAnchor), mediaTypeIndicotorView.trailingAnchor.constraint(equalTo: playerViewController.contentOverlayView!.trailingAnchor), - mediaTypeIndicotorView.heightAnchor.constraint(equalToConstant: 25).priority(.defaultHigh), - mediaTypeIndicotorView.widthAnchor.constraint(equalToConstant: 47).priority(.defaultHigh), + mediaTypeIndicotorView.heightAnchor.constraint(equalToConstant: MediaTypeIndicotorView.indicatorViewSize.height).priority(.defaultHigh), + mediaTypeIndicotorView.widthAnchor.constraint(equalToConstant: MediaTypeIndicotorView.indicatorViewSize.width).priority(.defaultHigh), ]) addSubview(contentWarningOverlayView) @@ -84,8 +84,8 @@ extension PlayerContainerView { NSLayoutConstraint.activate([ mediaTypeIndicotorViewInContentWarningOverlay.bottomAnchor.constraint(equalTo: contentWarningOverlayView.bottomAnchor), mediaTypeIndicotorViewInContentWarningOverlay.trailingAnchor.constraint(equalTo: contentWarningOverlayView.trailingAnchor), - mediaTypeIndicotorViewInContentWarningOverlay.heightAnchor.constraint(equalToConstant: 25).priority(.defaultHigh), - mediaTypeIndicotorViewInContentWarningOverlay.widthAnchor.constraint(equalToConstant: 47).priority(.defaultHigh), + mediaTypeIndicotorViewInContentWarningOverlay.heightAnchor.constraint(equalToConstant: MediaTypeIndicotorView.indicatorViewSize.height).priority(.defaultHigh), + mediaTypeIndicotorViewInContentWarningOverlay.widthAnchor.constraint(equalToConstant: MediaTypeIndicotorView.indicatorViewSize.width).priority(.defaultHigh), ]) } } From ea5b05107db150169c27b5656207dba664357d1f Mon Sep 17 00:00:00 2001 From: CMK Date: Mon, 15 Mar 2021 17:58:03 +0800 Subject: [PATCH 16/20] fix: add bottom-right corner radius and fix RTL layout issue for media indicator view --- .../PlayerContainerView+MediaTypeIndicotorView.swift | 4 ++++ Mastodon/Scene/Share/View/Container/PlayerContainerView.swift | 4 ++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/Mastodon/Scene/Share/View/Container/PlayerContainerView+MediaTypeIndicotorView.swift b/Mastodon/Scene/Share/View/Container/PlayerContainerView+MediaTypeIndicotorView.swift index 3bff6ef7..a76c2797 100644 --- a/Mastodon/Scene/Share/View/Container/PlayerContainerView+MediaTypeIndicotorView.swift +++ b/Mastodon/Scene/Share/View/Container/PlayerContainerView+MediaTypeIndicotorView.swift @@ -51,6 +51,10 @@ extension PlayerContainerView { maskLayer.frame = bounds maskLayer.path = path.cgPath layer.mask = maskLayer + + layer.cornerRadius = PlayerContainerView.cornerRadius + layer.maskedCorners = [.layerMaxXMaxYCorner] + layer.cornerCurve = .continuous } } diff --git a/Mastodon/Scene/Share/View/Container/PlayerContainerView.swift b/Mastodon/Scene/Share/View/Container/PlayerContainerView.swift index 3401bfe9..3a42560a 100644 --- a/Mastodon/Scene/Share/View/Container/PlayerContainerView.swift +++ b/Mastodon/Scene/Share/View/Container/PlayerContainerView.swift @@ -65,7 +65,7 @@ extension PlayerContainerView { playerViewController.contentOverlayView!.addSubview(mediaTypeIndicotorView) NSLayoutConstraint.activate([ mediaTypeIndicotorView.bottomAnchor.constraint(equalTo: playerViewController.contentOverlayView!.bottomAnchor), - mediaTypeIndicotorView.trailingAnchor.constraint(equalTo: playerViewController.contentOverlayView!.trailingAnchor), + mediaTypeIndicotorView.rightAnchor.constraint(equalTo: playerViewController.contentOverlayView!.rightAnchor), mediaTypeIndicotorView.heightAnchor.constraint(equalToConstant: MediaTypeIndicotorView.indicatorViewSize.height).priority(.defaultHigh), mediaTypeIndicotorView.widthAnchor.constraint(equalToConstant: MediaTypeIndicotorView.indicatorViewSize.width).priority(.defaultHigh), ]) @@ -83,7 +83,7 @@ extension PlayerContainerView { contentWarningOverlayView.addSubview(mediaTypeIndicotorViewInContentWarningOverlay) NSLayoutConstraint.activate([ mediaTypeIndicotorViewInContentWarningOverlay.bottomAnchor.constraint(equalTo: contentWarningOverlayView.bottomAnchor), - mediaTypeIndicotorViewInContentWarningOverlay.trailingAnchor.constraint(equalTo: contentWarningOverlayView.trailingAnchor), + mediaTypeIndicotorViewInContentWarningOverlay.rightAnchor.constraint(equalTo: contentWarningOverlayView.rightAnchor), mediaTypeIndicotorViewInContentWarningOverlay.heightAnchor.constraint(equalToConstant: MediaTypeIndicotorView.indicatorViewSize.height).priority(.defaultHigh), mediaTypeIndicotorViewInContentWarningOverlay.widthAnchor.constraint(equalToConstant: MediaTypeIndicotorView.indicatorViewSize.width).priority(.defaultHigh), ]) From 4005581d241754a6a5e2cebf11909792fe6adf51 Mon Sep 17 00:00:00 2001 From: CMK Date: Mon, 15 Mar 2021 18:02:42 +0800 Subject: [PATCH 17/20] chore: add Xcode previews --- ...ContainerView+MediaTypeIndicotorView.swift | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/Mastodon/Scene/Share/View/Container/PlayerContainerView+MediaTypeIndicotorView.swift b/Mastodon/Scene/Share/View/Container/PlayerContainerView+MediaTypeIndicotorView.swift index a76c2797..12f82298 100644 --- a/Mastodon/Scene/Share/View/Container/PlayerContainerView+MediaTypeIndicotorView.swift +++ b/Mastodon/Scene/Share/View/Container/PlayerContainerView+MediaTypeIndicotorView.swift @@ -100,4 +100,39 @@ extension PlayerContainerView.MediaTypeIndicotorView { } +#if canImport(SwiftUI) && DEBUG +import SwiftUI + +struct PlayerContainerViewMediaTypeIndicotorView_Previews: PreviewProvider { + + static var previews: some View { + Group { + UIViewPreview(width: 47) { + let view = PlayerContainerView.MediaTypeIndicotorView() + view.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + view.heightAnchor.constraint(equalToConstant: 25), + view.widthAnchor.constraint(equalToConstant: 47), + ]) + view.setMediaKind(kind: .gif) + return view + } + .previewLayout(.fixed(width: 47, height: 25)) + UIViewPreview(width: 47) { + let view = PlayerContainerView.MediaTypeIndicotorView() + view.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + view.heightAnchor.constraint(equalToConstant: 25), + view.widthAnchor.constraint(equalToConstant: 47), + ]) + view.setMediaKind(kind: .video) + return view + } + .previewLayout(.fixed(width: 47, height: 25)) + } + } + +} + +#endif From 6eb3816bab5e660ef75a5a19635b6fe3c2a2bd5d Mon Sep 17 00:00:00 2001 From: CMK Date: Mon, 15 Mar 2021 18:19:45 +0800 Subject: [PATCH 18/20] chore: renaming reblog --- .../Diffiable/Section/StatusSection.swift | 12 ++--- ...Provider+StatusTableViewCellDelegate.swift | 4 +- .../StatusProvider/StatusProviderFacade.swift | 39 ++++++++------ .../Scene/Share/View/Content/StatusView.swift | 4 +- .../TableviewCell/StatusTableViewCell.swift | 6 +-- .../View/ToolBar/ActionToolBarContainer.swift | 38 ++++++------- .../APIService/APIService+Reblog.swift | 28 ++++++---- .../MastodonSDK/API/Mastodon+API+Reblog.swift | 54 ++++++++----------- 8 files changed, 94 insertions(+), 91 deletions(-) diff --git a/Mastodon/Diffiable/Section/StatusSection.swift b/Mastodon/Diffiable/Section/StatusSection.swift index fad4898d..90669d39 100644 --- a/Mastodon/Diffiable/Section/StatusSection.swift +++ b/Mastodon/Diffiable/Section/StatusSection.swift @@ -294,7 +294,7 @@ extension StatusSection { let toot = object as? Toot else { return } StatusSection.configureActionToolBar(cell: cell, toot: toot, requestUserID: requestUserID) - os_log("%{public}s[%{public}ld], %{public}s: boost count label for toot %s did update: %ld", (#file as NSString).lastPathComponent, #line, #function, toot.id, toot.reblogsCount.intValue) + os_log("%{public}s[%{public}ld], %{public}s: reblog count label for toot %s did update: %ld", (#file as NSString).lastPathComponent, #line, #function, toot.id, toot.reblogsCount.intValue) os_log("%{public}s[%{public}ld], %{public}s: like count label for toot %s did update: %ld", (#file as NSString).lastPathComponent, #line, #function, toot.id, toot.favouritesCount.intValue) } .store(in: &cell.disposeBag) @@ -313,14 +313,14 @@ extension StatusSection { return StatusSection.formattedNumberTitleForActionButton(count) }() cell.statusView.actionToolbarContainer.replyButton.setTitle(replyCountTitle, for: .normal) - // set boost - let isBoosted = toot.rebloggedBy.flatMap { $0.contains(where: { $0.id == requestUserID }) } ?? false - let boostCountTitle: String = { + // set reblog + let isReblogged = toot.rebloggedBy.flatMap { $0.contains(where: { $0.id == requestUserID }) } ?? false + let reblogCountTitle: String = { let count = toot.reblogsCount.intValue return StatusSection.formattedNumberTitleForActionButton(count) }() - cell.statusView.actionToolbarContainer.boostButton.setTitle(boostCountTitle, for: .normal) - cell.statusView.actionToolbarContainer.isBoostButtonHighlight = isBoosted + cell.statusView.actionToolbarContainer.reblogButton.setTitle(reblogCountTitle, for: .normal) + cell.statusView.actionToolbarContainer.isReblogButtonHighlight = isReblogged // set like let isLike = toot.favouritedBy.flatMap { $0.contains(where: { $0.id == requestUserID }) } ?? false let favoriteCountTitle: String = { diff --git a/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift b/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift index fddd1304..7650471f 100644 --- a/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift +++ b/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift @@ -16,8 +16,8 @@ import ActiveLabel // MARK: - ActionToolbarContainerDelegate extension StatusTableViewCellDelegate where Self: StatusProvider { - func statusTableViewCell(_ cell: StatusTableViewCell, actionToolbarContainer: ActionToolbarContainer, boostButtonDidPressed sender: UIButton) { - StatusProviderFacade.responseToStatusBoostAction(provider: self, cell: cell) + func statusTableViewCell(_ cell: StatusTableViewCell, actionToolbarContainer: ActionToolbarContainer, reblogButtonDidPressed sender: UIButton) { + StatusProviderFacade.responseToStatusReblogAction(provider: self, cell: cell) } func statusTableViewCell(_ cell: StatusTableViewCell, actionToolbarContainer: ActionToolbarContainer, likeButtonDidPressed sender: UIButton) { diff --git a/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift b/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift index 9d1e3321..0febef17 100644 --- a/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift +++ b/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift @@ -130,7 +130,7 @@ extension StatusProviderFacade { ) } - static func responseToStatusBoostAction(provider: StatusProvider, cell: UITableViewCell) { + static func responseToStatusReblogAction(provider: StatusProvider, cell: UITableViewCell) { _responseToStatusBoostAction( provider: provider, toot: provider.toot(for: cell, indexPath: nil) @@ -160,21 +160,21 @@ extension StatusProviderFacade { let responseFeedbackGenerator = UIImpactFeedbackGenerator(style: .medium) toot - .compactMap { toot -> (NSManagedObjectID, Mastodon.API.Reblog.BoostKind)? in + .compactMap { toot -> (NSManagedObjectID, Mastodon.API.Reblog.ReblogKind)? in guard let toot = toot?.reblog ?? toot else { return nil } - let boostKind: Mastodon.API.Reblog.BoostKind = { - let isBoosted = toot.rebloggedBy.flatMap { $0.contains(where: { $0.id == mastodonUserID }) } ?? false - return isBoosted ? .undoBoost : .boost + let reblogKind: Mastodon.API.Reblog.ReblogKind = { + let isReblogged = toot.rebloggedBy.flatMap { $0.contains(where: { $0.id == mastodonUserID }) } ?? false + return isReblogged ? .undoReblog : .reblog(query: .init(visibility: nil)) }() - return (toot.objectID, boostKind) + return (toot.objectID, reblogKind) } - .map { tootObjectID, boostKind -> AnyPublisher<(Toot.ID, Mastodon.API.Reblog.BoostKind), Error> in - return context.apiService.boost( + .map { tootObjectID, reblogKind -> AnyPublisher<(Toot.ID, Mastodon.API.Reblog.ReblogKind), Error> in + return context.apiService.reblog( tootObjectID: tootObjectID, mastodonUserObjectID: mastodonUserObjectID, - boostKind: boostKind + reblogKind: reblogKind ) - .map { tootID in (tootID, boostKind) } + .map { tootID in (tootID, reblogKind) } .eraseToAnyPublisher() } .setFailureType(to: Error.self) @@ -184,9 +184,14 @@ extension StatusProviderFacade { .handleEvents { _ in generator.prepare() responseFeedbackGenerator.prepare() - } receiveOutput: { _, boostKind in + } receiveOutput: { _, reblogKind in generator.impactOccurred() - os_log("%{public}s[%{public}ld], %{public}s: [Boost] update local toot reblog status to: %s", ((#file as NSString).lastPathComponent), #line, #function, boostKind == .boost ? "boost" : "unboost") + switch reblogKind { + case .reblog: + os_log("%{public}s[%{public}ld], %{public}s: [Reblog] update local toot reblog status to: %s", ((#file as NSString).lastPathComponent), #line, #function, "reblog") + case .undoReblog: + os_log("%{public}s[%{public}ld], %{public}s: [Reblog] update local toot reblog status to: %s", ((#file as NSString).lastPathComponent), #line, #function, "unboost") + } } receiveCompletion: { completion in switch completion { case .failure: @@ -196,10 +201,10 @@ extension StatusProviderFacade { break } } - .map { tootID, boostKind in - return context.apiService.boost( + .map { tootID, reblogKind in + return context.apiService.reblog( statusID: tootID, - boostKind: boostKind, + reblogKind: reblogKind, mastodonAuthenticationBox: activeMastodonAuthenticationBox ) } @@ -212,9 +217,9 @@ extension StatusProviderFacade { } switch completion { case .failure(let error): - os_log("%{public}s[%{public}ld], %{public}s: [Boost] remote boost request fail: %{public}s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) + os_log("%{public}s[%{public}ld], %{public}s: [Reblog] remote reblog request fail: %{public}s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) case .finished: - os_log("%{public}s[%{public}ld], %{public}s: [Boost] remote boost request success", ((#file as NSString).lastPathComponent), #line, #function) + os_log("%{public}s[%{public}ld], %{public}s: [Reblog] remote reblog request success", ((#file as NSString).lastPathComponent), #line, #function) } } receiveValue: { response in // do nothing diff --git a/Mastodon/Scene/Share/View/Content/StatusView.swift b/Mastodon/Scene/Share/View/Content/StatusView.swift index 09b9ce28..27aa3ecf 100644 --- a/Mastodon/Scene/Share/View/Content/StatusView.swift +++ b/Mastodon/Scene/Share/View/Content/StatusView.swift @@ -49,7 +49,7 @@ final class StatusView: UIView { let label = UILabel() label.font = UIFontMetrics(forTextStyle: .footnote).scaledFont(for: .systemFont(ofSize: 13, weight: .medium)) label.textColor = Asset.Colors.Label.secondary.color - label.text = "Bob boosted" + label.text = "Bob reblogged" return label }() @@ -491,7 +491,7 @@ struct StatusView_Previews: PreviewProvider { return statusView } .previewLayout(.fixed(width: 375, height: 200)) - .previewDisplayName("Boost") + .previewDisplayName("Reblog") UIViewPreview(width: 375) { let statusView = StatusView(frame: CGRect(x: 0, y: 0, width: 375, height: 500)) statusView.configure( diff --git a/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift index 1723e47c..75845d6a 100644 --- a/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift +++ b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift @@ -25,7 +25,7 @@ protocol StatusTableViewCellDelegate: class { func statusTableViewCell(_ cell: StatusTableViewCell, mosaicImageViewContainer: MosaicImageViewContainer, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView) func statusTableViewCell(_ cell: StatusTableViewCell, mosaicImageViewContainer: MosaicImageViewContainer, didTapImageView imageView: UIImageView, atIndex index: Int) func statusTableViewCell(_ cell: StatusTableViewCell, playerContainerView: PlayerContainerView, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView) - func statusTableViewCell(_ cell: StatusTableViewCell, actionToolbarContainer: ActionToolbarContainer, boostButtonDidPressed sender: UIButton) + func statusTableViewCell(_ cell: StatusTableViewCell, actionToolbarContainer: ActionToolbarContainer, reblogButtonDidPressed sender: UIButton) func statusTableViewCell(_ cell: StatusTableViewCell, actionToolbarContainer: ActionToolbarContainer, likeButtonDidPressed sender: UIButton) func statusTableViewCell(_ cell: StatusTableViewCell, statusView: StatusView, pollVoteButtonPressed button: UIButton) @@ -227,8 +227,8 @@ extension StatusTableViewCell: ActionToolbarContainerDelegate { func actionToolbarContainer(_ actionToolbarContainer: ActionToolbarContainer, replayButtonDidPressed sender: UIButton) { } - func actionToolbarContainer(_ actionToolbarContainer: ActionToolbarContainer, boostButtonDidPressed sender: UIButton) { - delegate?.statusTableViewCell(self, actionToolbarContainer: actionToolbarContainer, boostButtonDidPressed: sender) + func actionToolbarContainer(_ actionToolbarContainer: ActionToolbarContainer, reblogButtonDidPressed sender: UIButton) { + delegate?.statusTableViewCell(self, actionToolbarContainer: actionToolbarContainer, reblogButtonDidPressed: sender) } func actionToolbarContainer(_ actionToolbarContainer: ActionToolbarContainer, starButtonDidPressed sender: UIButton) { delegate?.statusTableViewCell(self, actionToolbarContainer: actionToolbarContainer, likeButtonDidPressed: sender) diff --git a/Mastodon/Scene/Share/View/ToolBar/ActionToolBarContainer.swift b/Mastodon/Scene/Share/View/ToolBar/ActionToolBarContainer.swift index 991592c1..daaa607d 100644 --- a/Mastodon/Scene/Share/View/ToolBar/ActionToolBarContainer.swift +++ b/Mastodon/Scene/Share/View/ToolBar/ActionToolBarContainer.swift @@ -10,7 +10,7 @@ import UIKit protocol ActionToolbarContainerDelegate: class { func actionToolbarContainer(_ actionToolbarContainer: ActionToolbarContainer, replayButtonDidPressed sender: UIButton) - func actionToolbarContainer(_ actionToolbarContainer: ActionToolbarContainer, boostButtonDidPressed sender: UIButton) + func actionToolbarContainer(_ actionToolbarContainer: ActionToolbarContainer, reblogButtonDidPressed sender: UIButton) func actionToolbarContainer(_ actionToolbarContainer: ActionToolbarContainer, starButtonDidPressed sender: UIButton) func actionToolbarContainer(_ actionToolbarContainer: ActionToolbarContainer, moreButtonDidPressed sender: UIButton) } @@ -19,12 +19,12 @@ protocol ActionToolbarContainerDelegate: class { final class ActionToolbarContainer: UIView { let replyButton = HitTestExpandedButton() - let boostButton = HitTestExpandedButton() + let reblogButton = HitTestExpandedButton() let favoriteButton = HitTestExpandedButton() let moreButton = HitTestExpandedButton() - var isBoostButtonHighlight: Bool = false { - didSet { isBoostButtonHighlightStateDidChange(to: isBoostButtonHighlight) } + var isReblogButtonHighlight: Bool = false { + didSet { isReblogButtonHighlightStateDidChange(to: isReblogButtonHighlight) } } var isFavoriteButtonHighlight: Bool = false { @@ -61,7 +61,7 @@ extension ActionToolbarContainer { ]) replyButton.addTarget(self, action: #selector(ActionToolbarContainer.replyButtonDidPressed(_:)), for: .touchUpInside) - boostButton.addTarget(self, action: #selector(ActionToolbarContainer.boostButtonDidPressed(_:)), for: .touchUpInside) + reblogButton.addTarget(self, action: #selector(ActionToolbarContainer.reblogButtonDidPressed(_:)), for: .touchUpInside) favoriteButton.addTarget(self, action: #selector(ActionToolbarContainer.favoriteButtonDidPressed(_:)), for: .touchUpInside) moreButton.addTarget(self, action: #selector(ActionToolbarContainer.moreButtonDidPressed(_:)), for: .touchUpInside) } @@ -93,7 +93,7 @@ extension ActionToolbarContainer { subview.removeFromSuperview() } - let buttons = [replyButton, boostButton, favoriteButton, moreButton] + let buttons = [replyButton, reblogButton, favoriteButton, moreButton] buttons.forEach { button in button.tintColor = Asset.Colors.Button.actionToolbar.color button.titleLabel?.font = .monospacedDigitSystemFont(ofSize: 12, weight: .regular) @@ -113,7 +113,7 @@ extension ActionToolbarContainer { button.contentHorizontalAlignment = .leading } replyButton.setImage(replyImage, for: .normal) - boostButton.setImage(reblogImage, for: .normal) + reblogButton.setImage(reblogImage, for: .normal) favoriteButton.setImage(starImage, for: .normal) moreButton.setImage(moreImage, for: .normal) @@ -121,19 +121,19 @@ extension ActionToolbarContainer { container.distribution = .fill replyButton.translatesAutoresizingMaskIntoConstraints = false - boostButton.translatesAutoresizingMaskIntoConstraints = false + reblogButton.translatesAutoresizingMaskIntoConstraints = false favoriteButton.translatesAutoresizingMaskIntoConstraints = false moreButton.translatesAutoresizingMaskIntoConstraints = false container.addArrangedSubview(replyButton) - container.addArrangedSubview(boostButton) + container.addArrangedSubview(reblogButton) container.addArrangedSubview(favoriteButton) container.addArrangedSubview(moreButton) NSLayoutConstraint.activate([ replyButton.heightAnchor.constraint(equalToConstant: 44).priority(.defaultHigh), - replyButton.heightAnchor.constraint(equalTo: boostButton.heightAnchor).priority(.defaultHigh), + replyButton.heightAnchor.constraint(equalTo: reblogButton.heightAnchor).priority(.defaultHigh), replyButton.heightAnchor.constraint(equalTo: favoriteButton.heightAnchor).priority(.defaultHigh), replyButton.heightAnchor.constraint(equalTo: moreButton.heightAnchor).priority(.defaultHigh), - replyButton.widthAnchor.constraint(equalTo: boostButton.widthAnchor).priority(.defaultHigh), + replyButton.widthAnchor.constraint(equalTo: reblogButton.widthAnchor).priority(.defaultHigh), replyButton.widthAnchor.constraint(equalTo: favoriteButton.widthAnchor).priority(.defaultHigh), ]) moreButton.setContentHuggingPriority(.defaultHigh, for: .horizontal) @@ -144,7 +144,7 @@ extension ActionToolbarContainer { button.contentHorizontalAlignment = .center } replyButton.setImage(replyImage, for: .normal) - boostButton.setImage(reblogImage, for: .normal) + reblogButton.setImage(reblogImage, for: .normal) favoriteButton.setImage(starImage, for: .normal) container.axis = .horizontal @@ -152,7 +152,7 @@ extension ActionToolbarContainer { container.distribution = .fillEqually container.addArrangedSubview(replyButton) - container.addArrangedSubview(boostButton) + container.addArrangedSubview(reblogButton) container.addArrangedSubview(favoriteButton) } } @@ -162,11 +162,11 @@ extension ActionToolbarContainer { return oldStyle != style } - private func isBoostButtonHighlightStateDidChange(to isHighlight: Bool) { + private func isReblogButtonHighlightStateDidChange(to isHighlight: Bool) { let tintColor = isHighlight ? Asset.Colors.systemGreen.color : Asset.Colors.Button.actionToolbar.color - boostButton.tintColor = tintColor - boostButton.setTitleColor(tintColor, for: .normal) - boostButton.setTitleColor(tintColor, for: .highlighted) + reblogButton.tintColor = tintColor + reblogButton.setTitleColor(tintColor, for: .normal) + reblogButton.setTitleColor(tintColor, for: .highlighted) } private func isFavoriteButtonHighlightStateDidChange(to isHighlight: Bool) { @@ -184,9 +184,9 @@ extension ActionToolbarContainer { delegate?.actionToolbarContainer(self, replayButtonDidPressed: sender) } - @objc private func boostButtonDidPressed(_ sender: UIButton) { + @objc private func reblogButtonDidPressed(_ sender: UIButton) { os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) - delegate?.actionToolbarContainer(self, boostButtonDidPressed: sender) + delegate?.actionToolbarContainer(self, reblogButtonDidPressed: sender) } @objc private func favoriteButtonDidPressed(_ sender: UIButton) { diff --git a/Mastodon/Service/APIService/APIService+Reblog.swift b/Mastodon/Service/APIService/APIService+Reblog.swift index 3f750954..92ff85c1 100644 --- a/Mastodon/Service/APIService/APIService+Reblog.swift +++ b/Mastodon/Service/APIService/APIService+Reblog.swift @@ -15,10 +15,10 @@ import CommonOSLog extension APIService { // make local state change only - func boost( + func reblog( tootObjectID: NSManagedObjectID, mastodonUserObjectID: NSManagedObjectID, - boostKind: Mastodon.API.Reblog.BoostKind + reblogKind: Mastodon.API.Reblog.ReblogKind ) -> AnyPublisher { var _targetTootID: Toot.ID? let managedObjectContext = backgroundManagedObjectContext @@ -29,7 +29,12 @@ extension APIService { let targetTootID = targetToot.id _targetTootID = targetTootID - targetToot.update(reblogged: boostKind == .boost, mastodonUser: mastodonUser) + switch reblogKind { + case .reblog: + targetToot.update(reblogged: true, mastodonUser: mastodonUser) + case .undoReblog: + targetToot.update(reblogged: false, mastodonUser: mastodonUser) + } } .tryMap { result in @@ -48,20 +53,20 @@ extension APIService { .eraseToAnyPublisher() } - // send boost request to remote - func boost( + // send reblog request to remote + func reblog( statusID: Mastodon.Entity.Status.ID, - boostKind: Mastodon.API.Reblog.BoostKind, + reblogKind: Mastodon.API.Reblog.ReblogKind, mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox ) -> AnyPublisher, Error> { let domain = mastodonAuthenticationBox.domain let authorization = mastodonAuthenticationBox.userAuthorization let requestMastodonUserID = mastodonAuthenticationBox.userID - return Mastodon.API.Reblog.boost( + return Mastodon.API.Reblog.reblog( session: session, domain: domain, statusID: statusID, - boostKind: boostKind, + reblogKind: reblogKind, authorization: authorization ) .map { response -> AnyPublisher, Error> in @@ -92,10 +97,13 @@ extension APIService { } APIService.CoreData.merge(toot: oldToot, entity: entity.reblog ?? entity, requestMastodonUser: requestMastodonUser, domain: mastodonAuthenticationBox.domain, networkDate: response.networkDate) - if boostKind == .undoBoost { + switch reblogKind { + case .undoReblog: oldToot.update(reblogsCount: NSNumber(value: max(0, oldToot.reblogsCount.intValue - 1))) + default: + break } - os_log(.info, log: log, "%{public}s[%{public}ld], %{public}s: did update toot %{public}s reblog status to: %{public}s. now %ld boosts", ((#file as NSString).lastPathComponent), #line, #function, entity.id, entity.reblogged.flatMap { $0 ? "boost" : "unboost" } ?? "", entity.reblogsCount ) + os_log(.info, log: log, "%{public}s[%{public}ld], %{public}s: did update toot %{public}s reblog status to: %{public}s. now %ld reblog", ((#file as NSString).lastPathComponent), #line, #function, entity.id, entity.reblogged.flatMap { $0 ? "reblog" : "unreblog" } ?? "", entity.reblogsCount ) } .setFailureType(to: Error.self) .tryMap { result -> Mastodon.Response.Content in diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Reblog.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Reblog.swift index 862028d3..cb7a9618 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Reblog.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Reblog.swift @@ -10,7 +10,7 @@ import Combine extension Mastodon.API.Reblog { - static func boostedByEndpointURL(domain: String, statusID: Mastodon.Entity.Status.ID) -> URL { + static func rebloggedByEndpointURL(domain: String, statusID: Mastodon.Entity.Status.ID) -> URL { let pathComponent = "statuses/" + statusID + "/reblogged_by" return Mastodon.API.endpointURL(domain: domain).appendingPathComponent(pathComponent) } @@ -22,7 +22,7 @@ extension Mastodon.API.Reblog { /// - Since: 0.0.0 /// - Version: 3.3.0 /// # Last Update - /// 2021/3/9 + /// 2021/3/15 /// # Reference /// [Document](https://docs.joinmastodon.org/methods/statuses/) /// - Parameters: @@ -31,14 +31,14 @@ extension Mastodon.API.Reblog { /// - 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 poll( + public static func rebloggedBy( session: URLSession, domain: String, statusID: Mastodon.Entity.Status.ID, authorization: Mastodon.API.OAuth.Authorization? ) -> AnyPublisher, Error> { let request = Mastodon.API.get( - url: boostedByEndpointURL(domain: domain, statusID: statusID), + url: rebloggedByEndpointURL(domain: domain, statusID: statusID), query: nil, authorization: authorization ) @@ -66,7 +66,7 @@ extension Mastodon.API.Reblog { /// - Since: 0.0.0 /// - Version: 3.3.0 /// # Last Update - /// 2021/3/9 + /// 2021/3/15 /// # Reference /// [Document](https://docs.joinmastodon.org/methods/statuses/) /// - Parameters: @@ -75,11 +75,11 @@ extension Mastodon.API.Reblog { /// - statusID: id for status /// - authorization: User token. /// - Returns: `AnyPublisher` contains `Status` nested in the response - public static func boost( + public static func reblog( session: URLSession, domain: String, statusID: Mastodon.Entity.Status.ID, - query: BoostQuery, + query: ReblogQuery, authorization: Mastodon.API.OAuth.Authorization ) -> AnyPublisher, Error> { let request = Mastodon.API.post( @@ -97,10 +97,10 @@ extension Mastodon.API.Reblog { public typealias Visibility = Mastodon.Entity.Source.Privacy - public struct BoostQuery: Codable, PostQuery { - public let visibility: Visibility + public struct ReblogQuery: Codable, PostQuery { + public let visibility: Visibility? - public init(visibility: Visibility) { + public init(visibility: Visibility?) { self.visibility = visibility } } @@ -114,7 +114,7 @@ extension Mastodon.API.Reblog { return Mastodon.API.endpointURL(domain: domain).appendingPathComponent(pathComponent) } - /// Undo boost + /// Undo reblog /// /// Undo a reshare of a status. /// @@ -130,7 +130,7 @@ extension Mastodon.API.Reblog { /// - statusID: id for status /// - authorization: User token. /// - Returns: `AnyPublisher` contains `Status` nested in the response - public static func undoBoost( + public static func undoReblog( session: URLSession, domain: String, statusID: Mastodon.Entity.Status.ID, @@ -153,34 +153,24 @@ extension Mastodon.API.Reblog { extension Mastodon.API.Reblog { - public enum BoostKind { - case boost - case undoBoost + public enum ReblogKind { + case reblog(query: ReblogQuery) + case undoReblog } - public static func boost( + public static func reblog( session: URLSession, domain: String, statusID: Mastodon.Entity.Status.ID, - boostKind: BoostKind, + reblogKind: ReblogKind, authorization: Mastodon.API.OAuth.Authorization ) -> AnyPublisher, Error> { - let url: URL - switch boostKind { - case .boost: url = reblogEndpointURL(domain: domain, statusID: statusID) - case .undoBoost: url = unreblogEndpointURL(domain: domain, statusID: statusID) + switch reblogKind { + case .reblog(let query): + return reblog(session: session, domain: domain, statusID: statusID, query: query, authorization: authorization) + case .undoReblog: + return undoReblog(session: session, domain: domain, statusID: statusID, authorization: authorization) } - let request = Mastodon.API.post( - url: url, - 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() } } From fdf5b5fa663a7d98cdcf328731a146191a4c5931 Mon Sep 17 00:00:00 2001 From: CMK Date: Mon, 15 Mar 2021 18:22:44 +0800 Subject: [PATCH 19/20] chore: update i18n --- Localization/app.json | 2 +- Mastodon/Diffiable/Section/StatusSection.swift | 2 +- Mastodon/Generated/Strings.swift | 6 +++--- .../Protocol/StatusProvider/StatusProviderFacade.swift | 10 +++++----- Mastodon/Resources/en.lproj/Localizable.strings | 4 ++-- 5 files changed, 12 insertions(+), 12 deletions(-) diff --git a/Localization/app.json b/Localization/app.json index 12365595..4c79a97b 100644 --- a/Localization/app.json +++ b/Localization/app.json @@ -36,7 +36,7 @@ "open_in_safari": "Open in Safari" }, "status": { - "user_boosted": "%s boosted", + "user_reblogged": "%s reblogged", "show_post": "Show Post", "status_content_warning": "content warning", "media_content_warning": "Tap to reveal that may be sensitive", diff --git a/Mastodon/Diffiable/Section/StatusSection.swift b/Mastodon/Diffiable/Section/StatusSection.swift index 90669d39..4b0532d5 100644 --- a/Mastodon/Diffiable/Section/StatusSection.swift +++ b/Mastodon/Diffiable/Section/StatusSection.swift @@ -99,7 +99,7 @@ extension StatusSection { cell.statusView.headerInfoLabel.text = { let author = toot.author let name = author.displayName.isEmpty ? author.username : author.displayName - return L10n.Common.Controls.Status.userBoosted(name) + return L10n.Common.Controls.Status.userReblogged(name) }() // set name username diff --git a/Mastodon/Generated/Strings.swift b/Mastodon/Generated/Strings.swift index 7c595918..399d1a5e 100644 --- a/Mastodon/Generated/Strings.swift +++ b/Mastodon/Generated/Strings.swift @@ -76,9 +76,9 @@ internal enum L10n { internal static let showPost = L10n.tr("Localizable", "Common.Controls.Status.ShowPost") /// content warning internal static let statusContentWarning = L10n.tr("Localizable", "Common.Controls.Status.StatusContentWarning") - /// %@ boosted - internal static func userBoosted(_ p1: Any) -> String { - return L10n.tr("Localizable", "Common.Controls.Status.UserBoosted", String(describing: p1)) + /// %@ reblogged + internal static func userReblogged(_ p1: Any) -> String { + return L10n.tr("Localizable", "Common.Controls.Status.UserReblogged", String(describing: p1)) } internal enum Poll { /// Closed diff --git a/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift b/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift index 0febef17..cab16e68 100644 --- a/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift +++ b/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift @@ -123,21 +123,21 @@ extension StatusProviderFacade { extension StatusProviderFacade { - static func responseToStatusBoostAction(provider: StatusProvider) { - _responseToStatusBoostAction( + static func responseToStatusReblogAction(provider: StatusProvider) { + _responseToStatusReblogAction( provider: provider, toot: provider.toot() ) } static func responseToStatusReblogAction(provider: StatusProvider, cell: UITableViewCell) { - _responseToStatusBoostAction( + _responseToStatusReblogAction( provider: provider, toot: provider.toot(for: cell, indexPath: nil) ) } - private static func _responseToStatusBoostAction(provider: StatusProvider, toot: Future) { + private static func _responseToStatusReblogAction(provider: StatusProvider, toot: Future) { // prepare authentication guard let activeMastodonAuthenticationBox = provider.context.authenticationService.activeMastodonAuthenticationBox.value else { assertionFailure() @@ -190,7 +190,7 @@ extension StatusProviderFacade { case .reblog: os_log("%{public}s[%{public}ld], %{public}s: [Reblog] update local toot reblog status to: %s", ((#file as NSString).lastPathComponent), #line, #function, "reblog") case .undoReblog: - os_log("%{public}s[%{public}ld], %{public}s: [Reblog] update local toot reblog status to: %s", ((#file as NSString).lastPathComponent), #line, #function, "unboost") + os_log("%{public}s[%{public}ld], %{public}s: [Reblog] update local toot reblog status to: %s", ((#file as NSString).lastPathComponent), #line, #function, "unreblog") } } receiveCompletion: { completion in switch completion { diff --git a/Mastodon/Resources/en.lproj/Localizable.strings b/Mastodon/Resources/en.lproj/Localizable.strings index 54b69e27..c79e3d0c 100644 --- a/Mastodon/Resources/en.lproj/Localizable.strings +++ b/Mastodon/Resources/en.lproj/Localizable.strings @@ -30,7 +30,7 @@ "Common.Controls.Status.Poll.VoterCount.Single" = "%d voter"; "Common.Controls.Status.ShowPost" = "Show Post"; "Common.Controls.Status.StatusContentWarning" = "content warning"; -"Common.Controls.Status.UserBoosted" = "%@ boosted"; +"Common.Controls.Status.UserReblogged" = "%@ reblogged"; "Common.Controls.Timeline.LoadMore" = "Load More"; "Common.Countable.Photo.Multiple" = "photos"; "Common.Countable.Photo.Single" = "photo"; @@ -92,4 +92,4 @@ any server."; "Scene.ServerRules.Subtitle" = "These rules are set by the admins of %@."; "Scene.ServerRules.Title" = "Some ground rules."; "Scene.Welcome.Slogan" = "Social networking -back in your hands."; \ No newline at end of file +back in your hands."; From e6b9252e6c13a787d74ca2db75435f315fe2c164 Mon Sep 17 00:00:00 2001 From: CMK Date: Mon, 15 Mar 2021 19:31:31 +0800 Subject: [PATCH 20/20] chore: remove redundant layout --- .../View/Container/MosaicImageViewContainer.swift | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/Mastodon/Scene/Share/View/Container/MosaicImageViewContainer.swift b/Mastodon/Scene/Share/View/Container/MosaicImageViewContainer.swift index 8e688446..b3c03a46 100644 --- a/Mastodon/Scene/Share/View/Container/MosaicImageViewContainer.swift +++ b/Mastodon/Scene/Share/View/Container/MosaicImageViewContainer.swift @@ -63,7 +63,6 @@ extension MosaicImageViewContainer: ContentWarningOverlayViewDelegate { extension MosaicImageViewContainer { private func _init() { - contentWarningOverlayView.delegate = self container.translatesAutoresizingMaskIntoConstraints = false container.axis = .horizontal container.distribution = .fillEqually @@ -77,13 +76,7 @@ extension MosaicImageViewContainer { containerHeightLayoutConstraint ]) - addSubview(contentWarningOverlayView) - NSLayoutConstraint.activate([ - contentWarningOverlayView.topAnchor.constraint(equalTo: container.topAnchor), - contentWarningOverlayView.leadingAnchor.constraint(equalTo: container.leadingAnchor), - contentWarningOverlayView.bottomAnchor.constraint(equalTo: container.bottomAnchor), - contentWarningOverlayView.trailingAnchor.constraint(equalTo: container.trailingAnchor), - ]) + contentWarningOverlayView.delegate = self } } @@ -101,6 +94,7 @@ extension MosaicImageViewContainer { contentWarningOverlayView.removeFromSuperview() contentWarningOverlayView.blurVisualEffectView.effect = ContentWarningOverlayView.blurVisualEffect contentWarningOverlayView.vibrancyVisualEffectView.alpha = 1.0 + contentWarningOverlayView.isUserInteractionEnabled = true imageViews = [] container.spacing = 1