feat: implement boost for toot

This commit is contained in:
CMK 2021-03-09 15:18:43 +08:00
parent ce0fc56cd7
commit 441a6aee9e
16 changed files with 598 additions and 68 deletions

View File

@ -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) {

View File

@ -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 = "<group>"; };
DB9D6C3725E508BE0051B173 /* Attachment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Attachment.swift; sourceTree = "<group>"; };
DBBE1B4425F3474B0081417A /* MastodonPickServerAppearance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonPickServerAppearance.swift; sourceTree = "<group>"; };
DBCCC71D25F73297007E1AB6 /* APIService+Reblog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Reblog.swift"; sourceTree = "<group>"; };
DBD9148F25DF6D8D00903DFD /* APIService+Onboarding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Onboarding.swift"; sourceTree = "<group>"; };
DBE0821425CD382600FD6BBD /* MastodonRegisterViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonRegisterViewController.swift; sourceTree = "<group>"; };
DBE0822325CD3F1E00FD6BBD /* MastodonRegisterViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonRegisterViewModel.swift; sourceTree = "<group>"; };
@ -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;
};

View File

@ -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,

View File

@ -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 {

View File

@ -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)
}

View File

@ -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)

View File

@ -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<Toot?, Never>) {
// 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

View File

@ -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
}
}

View File

@ -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)

View File

@ -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)
}

View File

@ -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
}

View File

@ -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<Toot.ID, Error> {
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<Mastodon.Response.Content<Mastodon.Entity.Status>, 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<Mastodon.Response.Content<Mastodon.Entity.Status>, 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" } ?? "<nil>", entity.reblogsCount )
}
.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()
.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<Mastodon.Response.Content<[Mastodon.Entity.Status]>, 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<Mastodon.Response.Content<[Mastodon.Entity.Status]>, 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()
// }
}

View File

@ -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?

View File

@ -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<Mastodon.Response.Content<Mastodon.Entity.Status>, 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<Mastodon.Response.Content<Mastodon.Entity.Status>, 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<Mastodon.Response.Content<Mastodon.Entity.Status>, 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<Mastodon.Response.Content<Mastodon.Entity.Status>, 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()
}
}

View File

@ -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 { }
}

View File

@ -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 { }
}