feat: complete upload and publish logic

This commit is contained in:
CMK 2021-07-20 16:40:04 +08:00
parent 1cdbd7fa2a
commit d2f9828f50
59 changed files with 1003 additions and 425 deletions

View File

@ -22,7 +22,7 @@
"publish_post_failure": {
"title": "Publish Failure",
"message": "Failed to publish the post.\nPlease check your internet connection.",
"attchments_message": {
"attachments_message": {
"video_attach_with_photo": "Cannot attach a video to a post that already contains images.",
"more_than_one_video": "Cannot attach more than one video."
}

View File

@ -39,7 +39,6 @@
2D198643261BF09500F0B013 /* SearchResultItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D198642261BF09500F0B013 /* SearchResultItem.swift */; };
2D198649261C0B8500F0B013 /* SearchResultSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D198648261C0B8500F0B013 /* SearchResultSection.swift */; };
2D206B7225F5D27F00143C56 /* AudioContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D206B7125F5D27F00143C56 /* AudioContainerView.swift */; };
2D206B8025F5F45E00143C56 /* UIImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D206B7F25F5F45E00143C56 /* UIImage.swift */; };
2D206B8625F5FB0900143C56 /* Double.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D206B8525F5FB0900143C56 /* Double.swift */; };
2D206B8C25F6015000143C56 /* AudioPlaybackService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D206B8B25F6015000143C56 /* AudioPlaybackService.swift */; };
2D206B9225F60EA700143C56 /* UIControl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D206B9125F60EA700143C56 /* UIControl.swift */; };
@ -533,7 +532,6 @@
DBCBED1D26132E1A00B49291 /* StatusFetchedResultsController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBCBED1C26132E1A00B49291 /* StatusFetchedResultsController.swift */; };
DBCC3B30261440A50045B23D /* UITabBarController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBCC3B2F261440A50045B23D /* UITabBarController.swift */; };
DBCC3B36261440BA0045B23D /* UINavigationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBCC3B35261440BA0045B23D /* UINavigationController.swift */; };
DBCC3B89261454BA0045B23D /* CGImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBCC3B88261454BA0045B23D /* CGImage.swift */; };
DBCC3B8F26148F7B0045B23D /* CachedProfileViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBCC3B8E26148F7B0045B23D /* CachedProfileViewModel.swift */; };
DBCC3B9526157E6E0045B23D /* APIService+Relationship.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBCC3B9426157E6E0045B23D /* APIService+Relationship.swift */; };
DBCC3B9B261584A00045B23D /* PrivateNote.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBCC3B9A2615849F0045B23D /* PrivateNote.swift */; };
@ -569,6 +567,19 @@
DBFEF05F26A57715006D7ED1 /* StatusAuthorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBFEF05926A576EE006D7ED1 /* StatusAuthorView.swift */; };
DBFEF06026A57715006D7ED1 /* StatusAttachmentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBFEF05A26A576EE006D7ED1 /* StatusAttachmentView.swift */; };
DBFEF06326A577F2006D7ED1 /* StatusAttachmentViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBFEF06226A577F2006D7ED1 /* StatusAttachmentViewModel.swift */; };
DBFEF06826A67DEE006D7ED1 /* MastodonUser.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB45FAE225CA7181005A8AC7 /* MastodonUser.swift */; };
DBFEF06926A67E45006D7ED1 /* AppearancePreference.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6D1B3C2636857500ACB481 /* AppearancePreference.swift */; };
DBFEF06A26A67E53006D7ED1 /* Emojis.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBAFB7342645463500371D5F /* Emojis.swift */; };
DBFEF06B26A67E58006D7ED1 /* Fields.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBA94439265CC0FC00C537E1 /* Fields.swift */; };
DBFEF06D26A67FB7006D7ED1 /* StatusAttachmentViewModel+UploadState.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBFEF06C26A67FB7006D7ED1 /* StatusAttachmentViewModel+UploadState.swift */; };
DBFEF06F26A690C4006D7ED1 /* APIService+APIError.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB98337E25C9452D00AD9700 /* APIService+APIError.swift */; };
DBFEF07126A690E8006D7ED1 /* AlamofireNetworkActivityIndicator in Frameworks */ = {isa = PBXBuildFile; productRef = DBFEF07026A690E8006D7ED1 /* AlamofireNetworkActivityIndicator */; };
DBFEF07326A6913D006D7ED1 /* APIService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBFEF07226A6913D006D7ED1 /* APIService.swift */; };
DBFEF07526A69192006D7ED1 /* APIService+Media.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9A488F26035963008B817C /* APIService+Media.swift */; };
DBFEF07726A691FB006D7ED1 /* MastodonAuthenticationBox.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBFEF07626A691FB006D7ED1 /* MastodonAuthenticationBox.swift */; };
DBFEF07826A69209006D7ED1 /* MastodonAuthenticationBox.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBFEF07626A691FB006D7ED1 /* MastodonAuthenticationBox.swift */; };
DBFEF07B26A6BCE8006D7ED1 /* APIService+Status+Publish.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBFEF07A26A6BCE8006D7ED1 /* APIService+Status+Publish.swift */; };
DBFEF07C26A6BD0A006D7ED1 /* APIService+Status+Publish.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBFEF07A26A6BCE8006D7ED1 /* APIService+Status+Publish.swift */; };
EE93E8E8F9E0C39EAAEBD92F /* Pods_AppShared.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F4A2A2D7000E477CA459ADA9 /* Pods_AppShared.framework */; };
/* End PBXBuildFile section */
@ -727,7 +738,6 @@
2D198642261BF09500F0B013 /* SearchResultItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultItem.swift; sourceTree = "<group>"; };
2D198648261C0B8500F0B013 /* SearchResultSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultSection.swift; sourceTree = "<group>"; };
2D206B7125F5D27F00143C56 /* AudioContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioContainerView.swift; sourceTree = "<group>"; };
2D206B7F25F5F45E00143C56 /* UIImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIImage.swift; sourceTree = "<group>"; };
2D206B8525F5FB0900143C56 /* Double.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Double.swift; sourceTree = "<group>"; };
2D206B8B25F6015000143C56 /* AudioPlaybackService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioPlaybackService.swift; sourceTree = "<group>"; };
2D206B9125F60EA700143C56 /* UIControl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIControl.swift; sourceTree = "<group>"; };
@ -1204,7 +1214,6 @@
DBCBED1C26132E1A00B49291 /* StatusFetchedResultsController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusFetchedResultsController.swift; sourceTree = "<group>"; };
DBCC3B2F261440A50045B23D /* UITabBarController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UITabBarController.swift; sourceTree = "<group>"; };
DBCC3B35261440BA0045B23D /* UINavigationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UINavigationController.swift; sourceTree = "<group>"; };
DBCC3B88261454BA0045B23D /* CGImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CGImage.swift; sourceTree = "<group>"; };
DBCC3B8E26148F7B0045B23D /* CachedProfileViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CachedProfileViewModel.swift; sourceTree = "<group>"; };
DBCC3B9426157E6E0045B23D /* APIService+Relationship.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Relationship.swift"; sourceTree = "<group>"; };
DBCC3B9A2615849F0045B23D /* PrivateNote.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivateNote.swift; sourceTree = "<group>"; };
@ -1242,6 +1251,10 @@
DBFEF05A26A576EE006D7ED1 /* StatusAttachmentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusAttachmentView.swift; sourceTree = "<group>"; };
DBFEF06226A577F2006D7ED1 /* StatusAttachmentViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusAttachmentViewModel.swift; sourceTree = "<group>"; };
DBFEF06726A58D07006D7ED1 /* ShareActionExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = ShareActionExtension.entitlements; sourceTree = "<group>"; };
DBFEF06C26A67FB7006D7ED1 /* StatusAttachmentViewModel+UploadState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "StatusAttachmentViewModel+UploadState.swift"; sourceTree = "<group>"; };
DBFEF07226A6913D006D7ED1 /* APIService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIService.swift; sourceTree = "<group>"; };
DBFEF07626A691FB006D7ED1 /* MastodonAuthenticationBox.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonAuthenticationBox.swift; sourceTree = "<group>"; };
DBFEF07A26A6BCE8006D7ED1 /* APIService+Status+Publish.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Status+Publish.swift"; sourceTree = "<group>"; };
DDB1B139FA8EA26F510D58B6 /* Pods-AppShared.asdk - release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-AppShared.asdk - release.xcconfig"; path = "Target Support Files/Pods-AppShared/Pods-AppShared.asdk - release.xcconfig"; sourceTree = "<group>"; };
E5C7236E58D14A0322FE00F2 /* Pods-Mastodon-MastodonUITests.asdk - debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Mastodon-MastodonUITests.asdk - debug.xcconfig"; path = "Target Support Files/Pods-Mastodon-MastodonUITests/Pods-Mastodon-MastodonUITests.asdk - debug.xcconfig"; sourceTree = "<group>"; };
EC6E707B68A67DB08EC288FA /* Pods-MastodonTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-MastodonTests.debug.xcconfig"; path = "Target Support Files/Pods-MastodonTests/Pods-MastodonTests.debug.xcconfig"; sourceTree = "<group>"; };
@ -1341,6 +1354,7 @@
DB41ED8026A54D7C00F58330 /* AlamofireImage in Frameworks */,
DBC6463326A195DB00B0E31B /* AppShared.framework in Frameworks */,
4278334D6033AEEE0A1C5155 /* Pods_ShareActionExtension.framework in Frameworks */,
DBFEF07126A690E8006D7ED1 /* AlamofireNetworkActivityIndicator in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@ -2055,6 +2069,7 @@
DB0AC6FB25CD02E600D75117 /* APIService+Instance.swift */,
DB59F10D25EF724F001F1DAB /* APIService+Poll.swift */,
DB71FD5125F8CCAA00512AE1 /* APIService+Status.swift */,
DBFEF07A26A6BCE8006D7ED1 /* APIService+Status+Publish.swift */,
DB938F1426241FDF00E5B6C1 /* APIService+Thread.swift */,
DB49A62A25FF36C700B98345 /* APIService+CustomEmoji.swift */,
2D61254C262547C200299647 /* APIService+Notification.swift */,
@ -2497,8 +2512,6 @@
DBD376B1269302A4007FEC24 /* UITableViewCell.swift */,
0FAA101B25E10E760017CCDE /* UIFont.swift */,
2D939AB425EDD8A90076FA61 /* String.swift */,
2D206B7F25F5F45E00143C56 /* UIImage.swift */,
DBCC3B88261454BA0045B23D /* CGImage.swift */,
2D206B8525F5FB0900143C56 /* Double.swift */,
2D206B9125F60EA700143C56 /* UIControl.swift */,
5DF1056325F887CB00D6C0D4 /* AVPlayer.swift */,
@ -2764,6 +2777,7 @@
DBBC24D926A54BCB00398BB9 /* MastodonStatusContent+ParseResult.swift */,
DBBC24DA26A54BCB00398BB9 /* MastodonField.swift */,
DBBC24DB26A54BCB00398BB9 /* MastodonStatusContent+Appearance.swift */,
DBFEF07626A691FB006D7ED1 /* MastodonAuthenticationBox.swift */,
);
path = Helper;
sourceTree = "<group>";
@ -2791,6 +2805,7 @@
DBC6461926A170AB00B0E31B /* Info.plist */,
DBC6461626A170AB00B0E31B /* MainInterface.storyboard */,
DBFEF06126A57721006D7ED1 /* Scene */,
DBFEF07426A69140006D7ED1 /* Service */,
);
path = ShareActionExtension;
sourceTree = "<group>";
@ -2902,6 +2917,7 @@
DBFEF05926A576EE006D7ED1 /* StatusAuthorView.swift */,
DBFEF05A26A576EE006D7ED1 /* StatusAttachmentView.swift */,
DBFEF06226A577F2006D7ED1 /* StatusAttachmentViewModel.swift */,
DBFEF06C26A67FB7006D7ED1 /* StatusAttachmentViewModel+UploadState.swift */,
);
path = View;
sourceTree = "<group>";
@ -2916,6 +2932,14 @@
path = Scene;
sourceTree = "<group>";
};
DBFEF07426A69140006D7ED1 /* Service */ = {
isa = PBXGroup;
children = (
DBFEF07226A6913D006D7ED1 /* APIService.swift */,
);
path = Service;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXHeadersBuildPhase section */
@ -3111,6 +3135,7 @@
DB41ED7F26A54D7C00F58330 /* AlamofireImage */,
DB41ED8126A54D8A00F58330 /* MastodonMeta */,
DB41ED8326A54D8A00F58330 /* MetaTextView */,
DBFEF07026A690E8006D7ED1 /* AlamofireNetworkActivityIndicator */,
);
productName = ShareActionExtension;
productReference = DBC6461226A170AB00B0E31B /* ShareActionExtension.appex */;
@ -3821,6 +3846,7 @@
DB36679D268AB91B0027D07F /* ComposeStatusAttachmentTableViewCell.swift in Sources */,
DB98336B25C9420100AD9700 /* APIService+App.swift in Sources */,
DBBC24E026A54BCB00398BB9 /* MastodonField.swift in Sources */,
DBFEF07B26A6BCE8006D7ED1 /* APIService+Status+Publish.swift in Sources */,
DBCBCC032680AF6E000F5B51 /* AsyncHomeTimelineViewController+DebugAction.swift in Sources */,
DBA0A11325FB3FC10079C110 /* ComposeToolbarView.swift in Sources */,
2D32EADA25CBCC3300C9ED86 /* PublicTimelineViewModel+LoadMiddleState.swift in Sources */,
@ -3836,7 +3862,6 @@
2D34D9E226149C920081BFC0 /* SearchRecommendTagsCollectionViewCell.swift in Sources */,
2D76317D25C14DF500929FB9 /* PublicTimelineViewController+Provider.swift in Sources */,
0F20223326145E51000C64BF /* HashtagTimelineViewModel+LoadMiddleState.swift in Sources */,
2D206B8025F5F45E00143C56 /* UIImage.swift in Sources */,
DB6D9F502635761F008423CD /* SubscriptionAlerts.swift in Sources */,
0F20220726134DA4000C64BF /* HashtagTimelineViewModel+Diffable.swift in Sources */,
DBE54AC62636C89F004E7C0B /* NotificationPreference.swift in Sources */,
@ -3871,6 +3896,7 @@
DBBC24C026A5443100398BB9 /* MastodonTheme.swift in Sources */,
DBBC24B526A540AE00398BB9 /* AvatarConfigurableView.swift in Sources */,
DB9A489026035963008B817C /* APIService+Media.swift in Sources */,
DBFEF07726A691FB006D7ED1 /* MastodonAuthenticationBox.swift in Sources */,
DBBC24CF26A547AE00398BB9 /* ThemeService+Appearance.swift in Sources */,
2D198649261C0B8500F0B013 /* SearchResultSection.swift in Sources */,
DB4F097B26A039FF00D62E92 /* SearchHistorySection.swift in Sources */,
@ -3910,7 +3936,6 @@
2D6DE40026141DF600A63F6A /* SearchViewModel.swift in Sources */,
DB51D172262832380062B7A1 /* BlurHashDecode.swift in Sources */,
DBAFB7352645463500371D5F /* Emojis.swift in Sources */,
DBCC3B89261454BA0045B23D /* CGImage.swift in Sources */,
DBCCC71E25F73297007E1AB6 /* APIService+Reblog.swift in Sources */,
DBE3CE13261D7D4200430CC6 /* StatusTableViewControllerAspect.swift in Sources */,
5BB04FD5262E7AFF0043BFF6 /* ReportViewController.swift in Sources */,
@ -3993,21 +4018,28 @@
buildActionMask = 2147483647;
files = (
DBFEF05E26A57715006D7ED1 /* ComposeView.swift in Sources */,
DBFEF07326A6913D006D7ED1 /* APIService.swift in Sources */,
DB41ED7C26A54D5500F58330 /* MastodonStatusContent+Appearance.swift in Sources */,
DBFEF06026A57715006D7ED1 /* StatusAttachmentView.swift in Sources */,
DBC6462326A1712000B0E31B /* ShareViewModel.swift in Sources */,
DBBC24B326A53EE700398BB9 /* ActiveLabel.swift in Sources */,
DBFEF06D26A67FB7006D7ED1 /* StatusAttachmentViewModel+UploadState.swift in Sources */,
DBBC24CB26A546C000398BB9 /* ThemePreference.swift in Sources */,
DBFEF05F26A57715006D7ED1 /* StatusAuthorView.swift in Sources */,
DBFEF05D26A57715006D7ED1 /* ContentWarningEditorView.swift in Sources */,
DBFEF06A26A67E53006D7ED1 /* Emojis.swift in Sources */,
DBFEF07526A69192006D7ED1 /* APIService+Media.swift in Sources */,
DB41ED7B26A54D4D00F58330 /* MastodonStatusContent+ParseResult.swift in Sources */,
DB41ED8A26A54F4C00F58330 /* AttachmentContainerView.swift in Sources */,
DBFEF06F26A690C4006D7ED1 /* APIService+APIError.swift in Sources */,
DBFEF05C26A57715006D7ED1 /* StatusEditorView.swift in Sources */,
DBBC24C726A5456400398BB9 /* SystemTheme.swift in Sources */,
DBBC24B626A5419700398BB9 /* ComposeStatusContentTableViewCell.swift in Sources */,
DBC6462926A1736700B0E31B /* Strings.swift in Sources */,
DBBC24C826A5456400398BB9 /* ThemeService.swift in Sources */,
DBFEF06826A67DEE006D7ED1 /* MastodonUser.swift in Sources */,
DBBC24C926A5456400398BB9 /* MastodonTheme.swift in Sources */,
DBFEF07C26A6BD0A006D7ED1 /* APIService+Status+Publish.swift in Sources */,
DBBC24B926A5426000398BB9 /* StatusContentWarningEditorView.swift in Sources */,
DB41ED8B26A54F5800F58330 /* AttachmentContainerView+EmptyStateView.swift in Sources */,
DBBC24A826A52F9000398BB9 /* ComposeToolbarView.swift in Sources */,
@ -4019,6 +4051,9 @@
DB41ED8926A54F4000F58330 /* ComposeStatusAttachmentCollectionViewCell.swift in Sources */,
DBBC24D226A5488600398BB9 /* AvatarConfigurableView.swift in Sources */,
DBC6462C26A176B000B0E31B /* Assets.swift in Sources */,
DBFEF06926A67E45006D7ED1 /* AppearancePreference.swift in Sources */,
DBFEF06B26A67E58006D7ED1 /* Fields.swift in Sources */,
DBFEF07826A69209006D7ED1 /* MastodonAuthenticationBox.swift in Sources */,
DBC6461526A170AB00B0E31B /* ShareViewController.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
@ -5579,6 +5614,11 @@
package = DBF7A0FA26830C33004176A2 /* XCRemoteSwiftPackageReference "FPSIndicator" */;
productName = FPSIndicator;
};
DBFEF07026A690E8006D7ED1 /* AlamofireNetworkActivityIndicator */ = {
isa = XCSwiftPackageProductDependency;
package = 2D61336725C18A4F00CAE157 /* XCRemoteSwiftPackageReference "AlamofireNetworkActivityIndicator" */;
productName = AlamofireNetworkActivityIndicator;
};
/* End XCSwiftPackageProductDependency section */
/* Begin XCVersionGroup section */

View File

@ -1,154 +0,0 @@
//
// CGImage.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-3-31.
//
import CoreImage
extension CGImage {
// Reference
// https://www.itu.int/dms_pubrec/itu-r/rec/bt/R-REC-BT.709-6-201506-I!!PDF-E.pdf
// Luma Y = 0.2126R + 0.7152G + 0.0722B
var brightness: CGFloat? {
let context = CIContext() // default with metal accelerate
let ciImage = CIImage(cgImage: self)
let rec709Image = context.createCGImage(
ciImage,
from: ciImage.extent,
format: .RGBA8,
colorSpace: CGColorSpace(name: CGColorSpace.itur_709) // BT.709 a.k.a Rec.709
)
guard let image = rec709Image,
image.bitsPerPixel == 32,
let data = rec709Image?.dataProvider?.data,
let pointer = CFDataGetBytePtr(data) else { return nil }
let length = CFDataGetLength(data)
guard length > 0 else { return nil }
var luma: CGFloat = 0.0
for i in stride(from: 0, to: length, by: 4) {
let r = pointer[i]
let g = pointer[i + 1]
let b = pointer[i + 2]
let Y = 0.2126 * CGFloat(r) + 0.7152 * CGFloat(g) + 0.0722 * CGFloat(b)
luma += Y
}
luma /= CGFloat(width * height)
return luma
}
}
#if canImport(SwiftUI) && DEBUG
import SwiftUI
import UIKit
class BrightnessView: UIView {
let label = UILabel()
let imageView = UIImageView()
override init(frame: CGRect) {
super.init(frame: frame)
let stackView = UIStackView()
stackView.axis = .horizontal
stackView.translatesAutoresizingMaskIntoConstraints = false
addSubview(stackView)
NSLayoutConstraint.activate([
stackView.topAnchor.constraint(equalTo: topAnchor),
stackView.leadingAnchor.constraint(equalTo: leadingAnchor),
stackView.trailingAnchor.constraint(equalTo: trailingAnchor),
stackView.bottomAnchor.constraint(equalTo: bottomAnchor),
])
stackView.distribution = .fillEqually
stackView.addArrangedSubview(imageView)
stackView.addArrangedSubview(label)
imageView.contentMode = .scaleAspectFill
imageView.layer.masksToBounds = true
label.textAlignment = .center
label.numberOfLines = 0
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func setImage(_ image: UIImage) {
imageView.image = image
guard let brightness = image.cgImage?.brightness,
let style = image.domainLumaCoefficientsStyle else {
label.text = "<nil>"
return
}
let styleDescription: String = {
switch style {
case .light: return "Light"
case .dark: return "Dark"
case .unspecified: fallthrough
@unknown default:
return "Unknown"
}
}()
label.text = styleDescription + "\n" + "\(brightness)"
}
}
struct CGImage_Brightness_Previews: PreviewProvider {
static var previews: some View {
Group {
UIViewPreview(width: 375) {
let view = BrightnessView()
view.setImage(.placeholder(color: .black))
return view
}
.previewLayout(.fixed(width: 375, height: 44))
UIViewPreview(width: 375) {
let view = BrightnessView()
view.setImage(.placeholder(color: .gray))
return view
}
.previewLayout(.fixed(width: 375, height: 44))
UIViewPreview(width: 375) {
let view = BrightnessView()
view.setImage(.placeholder(color: .separator))
return view
}
.previewLayout(.fixed(width: 375, height: 44))
UIViewPreview(width: 375) {
let view = BrightnessView()
view.setImage(.placeholder(color: .red))
return view
}
.previewLayout(.fixed(width: 375, height: 44))
UIViewPreview(width: 375) {
let view = BrightnessView()
view.setImage(.placeholder(color: .green))
return view
}
.previewLayout(.fixed(width: 375, height: 44))
UIViewPreview(width: 375) {
let view = BrightnessView()
view.setImage(.placeholder(color: .blue))
return view
}
.previewLayout(.fixed(width: 375, height: 44))
UIViewPreview(width: 375) {
let view = BrightnessView()
view.setImage(.placeholder(color: .secondarySystemGroupedBackground))
return view
}
.previewLayout(.fixed(width: 375, height: 44))
}
}
}
#endif

View File

@ -1,81 +0,0 @@
//
// UIImage.swift
// Mastodon
//
// Created by sxiaojian on 2021/3/8.
//
import CoreImage
import CoreImage.CIFilterBuiltins
import UIKit
// refs: https://www.hackingwithswift.com/example-code/media/how-to-read-the-average-color-of-a-uiimage-using-ciareaaverage
extension UIImage {
@available(iOS 14.0, *)
var dominantColor: UIColor? {
guard let inputImage = CIImage(image: self) else { return nil }
let filter = CIFilter.areaAverage()
filter.inputImage = inputImage
filter.extent = inputImage.extent
guard let outputImage = filter.outputImage else { return nil }
var bitmap = [UInt8](repeating: 0, count: 4)
let context = CIContext(options: [.workingColorSpace: kCFNull])
context.render(outputImage, toBitmap: &bitmap, rowBytes: 4, bounds: CGRect(x: 0, y: 0, width: 1, height: 1), format: .RGBA8, colorSpace: nil)
return UIColor(red: CGFloat(bitmap[0]) / 255, green: CGFloat(bitmap[1]) / 255, blue: CGFloat(bitmap[2]) / 255, alpha: CGFloat(bitmap[3]) / 255)
}
}
extension UIImage {
var domainLumaCoefficientsStyle: UIUserInterfaceStyle? {
guard let brightness = cgImage?.brightness else { return nil }
return brightness > 100 ? .light : .dark // 0 ~ 255
}
}
extension UIImage {
func blur(radius: CGFloat) -> UIImage? {
guard let inputImage = CIImage(image: self) else { return nil }
let blurFilter = CIFilter.gaussianBlur()
blurFilter.inputImage = inputImage
blurFilter.radius = Float(radius)
guard let outputImage = blurFilter.outputImage else { return nil }
guard let cgImage = CIContext().createCGImage(outputImage, from: outputImage.extent) else { return nil }
let image = UIImage(cgImage: cgImage, scale: scale, orientation: imageOrientation)
return image
}
}
extension UIImage {
func withRoundedCorners(radius: CGFloat? = nil) -> UIImage? {
let maxRadius = min(size.width, size.height) / 2
let cornerRadius: CGFloat = {
guard let radius = radius, radius > 0 else { return maxRadius }
return min(radius, maxRadius)
}()
let render = UIGraphicsImageRenderer(size: size)
return render.image { (_: UIGraphicsImageRendererContext) in
let rect = CGRect(origin: .zero, size: size)
UIBezierPath(roundedRect: rect, cornerRadius: cornerRadius).addClip()
draw(in: rect)
}
}
}
extension UIImage {
static func adaptiveUserInterfaceStyleImage(lightImage: UIImage, darkImage: UIImage) -> UIImage {
let imageAsset = UIImageAsset()
imageAsset.register(lightImage, with: UITraitCollection(traitsFrom: [
UITraitCollection(displayScale: 1.0),
UITraitCollection(userInterfaceStyle: .light)
]))
imageAsset.register(darkImage, with: UITraitCollection(traitsFrom: [
UITraitCollection(displayScale: 1.0),
UITraitCollection(userInterfaceStyle: .dark)
]))
return imageAsset.image(with: UITraitCollection.current)
}
}

View File

@ -58,11 +58,11 @@ internal enum L10n {
internal static let message = L10n.tr("Localizable", "Common.Alerts.PublishPostFailure.Message")
/// Publish Failure
internal static let title = L10n.tr("Localizable", "Common.Alerts.PublishPostFailure.Title")
internal enum AttchmentsMessage {
internal enum AttachmentsMessage {
/// Cannot attach more than one video.
internal static let moreThanOneVideo = L10n.tr("Localizable", "Common.Alerts.PublishPostFailure.AttchmentsMessage.MoreThanOneVideo")
internal static let moreThanOneVideo = L10n.tr("Localizable", "Common.Alerts.PublishPostFailure.AttachmentsMessage.MoreThanOneVideo")
/// Cannot attach a video to a post that already contains images.
internal static let videoAttachWithPhoto = L10n.tr("Localizable", "Common.Alerts.PublishPostFailure.AttchmentsMessage.VideoAttachWithPhoto")
internal static let videoAttachWithPhoto = L10n.tr("Localizable", "Common.Alerts.PublishPostFailure.AttachmentsMessage.VideoAttachWithPhoto")
}
}
internal enum SavePhotoFailure {

View File

@ -0,0 +1,17 @@
//
// MastodonAuthenticationBox.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-7-20.
//
import Foundation
import MastodonSDK
import CoreDataStack
struct MastodonAuthenticationBox {
let domain: String
let userID: MastodonUser.ID
let appAuthorization: Mastodon.API.OAuth.Authorization
let userAuthorization: Mastodon.API.OAuth.Authorization
}

View File

@ -49,7 +49,7 @@ extension UserProviderFacade {
private static func _toggleUserFollowRelationship(
context: AppContext,
activeMastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox,
activeMastodonAuthenticationBox: MastodonAuthenticationBox,
mastodonUser: AnyPublisher<MastodonUser?, Never>
) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Relationship>, Error> {
mastodonUser
@ -111,7 +111,7 @@ extension UserProviderFacade {
private static func _toggleUserBlockRelationship(
context: AppContext,
activeMastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox,
activeMastodonAuthenticationBox: MastodonAuthenticationBox,
mastodonUser: AnyPublisher<MastodonUser?, Never>
) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Relationship>, Error> {
mastodonUser
@ -174,7 +174,7 @@ extension UserProviderFacade {
private static func _toggleUserMuteRelationship(
context: AppContext,
activeMastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox,
activeMastodonAuthenticationBox: MastodonAuthenticationBox,
mastodonUser: AnyPublisher<MastodonUser?, Never>
) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Relationship>, Error> {
mastodonUser

View File

@ -10,8 +10,8 @@
"Common.Alerts.DiscardPostContent.Title" = "Discard Draft";
"Common.Alerts.EditProfileFailure.Message" = "Cannot edit profile. Please try again.";
"Common.Alerts.EditProfileFailure.Title" = "Edit Profile Error";
"Common.Alerts.PublishPostFailure.AttchmentsMessage.MoreThanOneVideo" = "Cannot attach more than one video.";
"Common.Alerts.PublishPostFailure.AttchmentsMessage.VideoAttachWithPhoto" = "Cannot attach a video to a post that already contains images.";
"Common.Alerts.PublishPostFailure.AttachmentsMessage.MoreThanOneVideo" = "Cannot attach more than one video.";
"Common.Alerts.PublishPostFailure.AttachmentsMessage.VideoAttachWithPhoto" = "Cannot attach a video to a post that already contains images.";
"Common.Alerts.PublishPostFailure.Message" = "Failed to publish the post.
Please check your internet connection.";
"Common.Alerts.PublishPostFailure.Title" = "Publish Failure";

View File

@ -10,8 +10,8 @@
"Common.Alerts.DiscardPostContent.Title" = "Discard Draft";
"Common.Alerts.EditProfileFailure.Message" = "Cannot edit profile. Please try again.";
"Common.Alerts.EditProfileFailure.Title" = "Edit Profile Error";
"Common.Alerts.PublishPostFailure.AttchmentsMessage.MoreThanOneVideo" = "Cannot attach more than one video.";
"Common.Alerts.PublishPostFailure.AttchmentsMessage.VideoAttachWithPhoto" = "Cannot attach a video to a post that already contains images.";
"Common.Alerts.PublishPostFailure.AttachmentsMessage.MoreThanOneVideo" = "Cannot attach more than one video.";
"Common.Alerts.PublishPostFailure.AttachmentsMessage.VideoAttachWithPhoto" = "Cannot attach a video to a post that already contains images.";
"Common.Alerts.PublishPostFailure.Message" = "Failed to publish the post.
Please check your internet connection.";
"Common.Alerts.PublishPostFailure.Title" = "Publish Failure";

View File

@ -28,7 +28,7 @@ final class ComposeViewModel: NSObject {
let isContentWarningComposing = CurrentValueSubject<Bool, Never>(false)
let selectedStatusVisibility: CurrentValueSubject<ComposeToolbarView.VisibilitySelectionType, Never>
let activeAuthentication: CurrentValueSubject<MastodonAuthentication?, Never>
let activeAuthenticationBox: CurrentValueSubject<AuthenticationService.MastodonAuthenticationBox?, Never>
let activeAuthenticationBox: CurrentValueSubject<MastodonAuthenticationBox?, Never>
let traitCollectionDidChangePublisher = CurrentValueSubject<Void, Never>(Void()) // use CurrentValueSubject to make initial event emit
let repliedToCellFrame = CurrentValueSubject<CGRect, Never>(.zero)
let autoCompleteRetryLayoutTimes = CurrentValueSubject<Int, Never>(0)
@ -202,13 +202,13 @@ final class ComposeViewModel: NSObject {
}
.assign(to: \.value, on: characterCount)
.store(in: &disposeBag)
// bind compose bar button item UI state
let isComposeContentEmpty = composeStatusAttribute.composeContent
.map { ($0 ?? "").isEmpty }
let isComposeContentValid = composeStatusAttribute.composeContent
.map { composeContent -> Bool in
let composeContent = composeContent ?? ""
return composeContent.count <= ComposeViewModel.composeContentLimit
let isComposeContentValid = characterCount
.map { characterCount -> Bool in
return characterCount <= ComposeViewModel.composeContentLimit
}
let isMediaEmpty = attachmentServices
.map { $0.isEmpty }
@ -224,10 +224,10 @@ final class ComposeViewModel: NSObject {
}
let isPublishBarButtonItemEnabledPrecondition1 = Publishers.CombineLatest4(
isComposeContentEmpty.eraseToAnyPublisher(),
isComposeContentValid.eraseToAnyPublisher(),
isMediaEmpty.eraseToAnyPublisher(),
isMediaUploadAllSuccess.eraseToAnyPublisher()
isComposeContentEmpty,
isComposeContentValid,
isMediaEmpty,
isMediaUploadAllSuccess
)
.map { isComposeContentEmpty, isComposeContentValid, isMediaEmpty, isMediaUploadAllSuccess -> Bool in
if isMediaEmpty {
@ -239,10 +239,10 @@ final class ComposeViewModel: NSObject {
.eraseToAnyPublisher()
let isPublishBarButtonItemEnabledPrecondition2 = Publishers.CombineLatest4(
isComposeContentEmpty.eraseToAnyPublisher(),
isComposeContentValid.eraseToAnyPublisher(),
isPollComposing.eraseToAnyPublisher(),
isPollAttributeAllValid.eraseToAnyPublisher()
isComposeContentEmpty,
isComposeContentValid,
isPollComposing,
isPollAttributeAllValid
)
.map { isComposeContentEmpty, isComposeContentValid, isPollComposing, isPollAttributeAllValid -> Bool in
if isPollComposing {
@ -390,9 +390,9 @@ extension ComposeViewModel {
var failureReason: String? {
switch self {
case .videoAttachWithPhoto:
return L10n.Common.Alerts.PublishPostFailure.AttchmentsMessage.videoAttachWithPhoto
return L10n.Common.Alerts.PublishPostFailure.AttachmentsMessage.videoAttachWithPhoto
case .moreThanOneVideo:
return L10n.Common.Alerts.PublishPostFailure.AttchmentsMessage.moreThanOneVideo
return L10n.Common.Alerts.PublishPostFailure.AttachmentsMessage.moreThanOneVideo
}
}
}

View File

@ -116,9 +116,11 @@ extension ComposeStatusAttachmentTableViewCell {
} else {
guard let uploadState = uploadState else { return }
switch uploadState {
case is MastodonAttachmentService.UploadState.Finish,
is MastodonAttachmentService.UploadState.Fail:
case is MastodonAttachmentService.UploadState.Finish:
cell.attachmentContainerView.activityIndicatorView.stopAnimating()
case is MastodonAttachmentService.UploadState.Fail:
cell.attachmentContainerView.activityIndicatorView.stopAnimating()
// FIXME: not display
cell.attachmentContainerView.emptyStateView.label.text = {
if let file = attachmentService.file.value {
switch file {

View File

@ -26,7 +26,7 @@ final class NotificationViewModel: NSObject {
let selectedIndex = CurrentValueSubject<NotificationSegment, Never>(.EveryThing)
let noMoreNotification = CurrentValueSubject<Bool, Never>(false)
let activeMastodonAuthenticationBox: CurrentValueSubject<AuthenticationService.MastodonAuthenticationBox?, Never>
let activeMastodonAuthenticationBox: CurrentValueSubject<MastodonAuthenticationBox?, Never>
let fetchedResultsController: NSFetchedResultsController<MastodonNotification>!
let notificationPredicate = CurrentValueSubject<NSPredicate?, Never>(nil)
let cellFrameCache = NSCache<NSString, NSValue>()

View File

@ -17,7 +17,7 @@ final class FavoriteViewModel {
// input
let context: AppContext
let activeMastodonAuthenticationBox: CurrentValueSubject<AuthenticationService.MastodonAuthenticationBox?, Never>
let activeMastodonAuthenticationBox: CurrentValueSubject<MastodonAuthenticationBox?, Never>
let statusFetchedResultsController: StatusFetchedResultsController
let cellFrameCache = NSCache<NSNumber, NSValue>()

View File

@ -115,7 +115,7 @@ class ProfileViewModel: NSObject {
context.authenticationService.activeMastodonAuthenticationBox.eraseToAnyPublisher(),
pendingRetryPublisher.eraseToAnyPublisher()
)
.compactMap { mastodonUserID, activeMastodonAuthenticationBox, _ -> (String, AuthenticationService.MastodonAuthenticationBox)? in
.compactMap { mastodonUserID, activeMastodonAuthenticationBox, _ -> (String, MastodonAuthenticationBox)? in
guard let mastodonUserID = mastodonUserID, let activeMastodonAuthenticationBox = activeMastodonAuthenticationBox else { return nil }
guard mastodonUserID != activeMastodonAuthenticationBox.userID else { return nil }
return (mastodonUserID, activeMastodonAuthenticationBox)

View File

@ -17,7 +17,7 @@ extension ReportViewModel {
func requestRecentStatus(
domain: String,
accountId: String,
authorizationBox: AuthenticationService.MastodonAuthenticationBox
authorizationBox: MastodonAuthenticationBox
) {
context.apiService.userTimeline(
domain: domain,

View File

@ -165,7 +165,7 @@ class ReportViewModel: NSObject {
.store(in: &disposeBag)
}
func bindForStep2(input: Input, domain: String, activeMastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox) -> AnyPublisher<(Bool, Error?), Never> {
func bindForStep2(input: Input, domain: String, activeMastodonAuthenticationBox: MastodonAuthenticationBox) -> AnyPublisher<(Bool, Error?), Never> {
let skip = input.step2Skip.map { [weak self] value -> Void in
guard let self = self else { return value }
self.reportQuery.comment = nil

View File

@ -42,7 +42,7 @@ final class SearchViewModel: NSObject {
context.authenticationService.activeMastodonAuthenticationBox,
viewDidAppeared
)
.compactMap { activeMastodonAuthenticationBox, _ -> AuthenticationService.MastodonAuthenticationBox? in
.compactMap { activeMastodonAuthenticationBox, _ -> MastodonAuthenticationBox? in
return activeMastodonAuthenticationBox
}
.throttle(for: 1, scheduler: DispatchQueue.main, latest: false)
@ -72,7 +72,7 @@ final class SearchViewModel: NSObject {
context.authenticationService.activeMastodonAuthenticationBox,
viewDidAppeared
)
.compactMap { activeMastodonAuthenticationBox, _ -> AuthenticationService.MastodonAuthenticationBox? in
.compactMap { activeMastodonAuthenticationBox, _ -> MastodonAuthenticationBox? in
return activeMastodonAuthenticationBox
}
.throttle(for: 1, scheduler: DispatchQueue.main, latest: false)

View File

@ -16,7 +16,7 @@ extension APIService {
func toggleBlock(
for mastodonUser: MastodonUser,
activeMastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox
activeMastodonAuthenticationBox: MastodonAuthenticationBox
) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Relationship>, Error> {
let impactFeedbackGenerator = UIImpactFeedbackGenerator(style: .light)
let notificationFeedbackGenerator = UINotificationFeedbackGenerator()
@ -86,7 +86,7 @@ extension APIService {
// update database local and return block query update type for remote request
func blockUpdateLocal(
mastodonUserObjectID: NSManagedObjectID,
mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox
mastodonAuthenticationBox: MastodonAuthenticationBox
) -> AnyPublisher<(Mastodon.API.Account.BlockQueryType, MastodonUser.ID), Error> {
let domain = mastodonAuthenticationBox.domain
let requestMastodonUserID = mastodonAuthenticationBox.userID
@ -132,7 +132,7 @@ extension APIService {
func blockUpdateRemote(
blockQueryType: Mastodon.API.Account.BlockQueryType,
mastodonUserID: MastodonUser.ID,
mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox
mastodonAuthenticationBox: MastodonAuthenticationBox
) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Relationship>, Error> {
let domain = mastodonAuthenticationBox.domain
let authorization = mastodonAuthenticationBox.userAuthorization

View File

@ -17,7 +17,7 @@ extension APIService {
func getDomainblocks(
domain: String,
limit: Int = onceRequestDomainBlocksMaxCount,
authorizationBox: AuthenticationService.MastodonAuthenticationBox
authorizationBox: MastodonAuthenticationBox
) -> AnyPublisher<Mastodon.Response.Content<[String]>, Error> {
let authorization = authorizationBox.userAuthorization
@ -71,7 +71,7 @@ extension APIService {
func blockDomain(
user: MastodonUser,
authorizationBox: AuthenticationService.MastodonAuthenticationBox
authorizationBox: MastodonAuthenticationBox
) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Empty>, Error> {
let authorization = authorizationBox.userAuthorization
@ -105,7 +105,7 @@ extension APIService {
func unblockDomain(
user: MastodonUser,
authorizationBox: AuthenticationService.MastodonAuthenticationBox
authorizationBox: MastodonAuthenticationBox
) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Empty>, Error> {
let authorization = authorizationBox.userAuthorization

View File

@ -61,7 +61,7 @@ extension APIService {
func favorite(
statusID: Mastodon.Entity.Status.ID,
favoriteKind: Mastodon.API.Favorites.FavoriteKind,
mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox
mastodonAuthenticationBox: MastodonAuthenticationBox
) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Status>, Error> {
let authorization = mastodonAuthenticationBox.userAuthorization
let requestMastodonUserID = mastodonAuthenticationBox.userID
@ -139,7 +139,7 @@ extension APIService {
func favoritedStatuses(
limit: Int = onceRequestStatusMaxCount,
maxID: String? = nil,
mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox
mastodonAuthenticationBox: MastodonAuthenticationBox
) -> AnyPublisher<Mastodon.Response.Content<[Mastodon.Entity.Status]>, Error> {
let requestMastodonUserID = mastodonAuthenticationBox.userID

View File

@ -15,7 +15,7 @@ import MastodonSDK
extension APIService {
func filters(
mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox
mastodonAuthenticationBox: MastodonAuthenticationBox
) -> AnyPublisher<Mastodon.Response.Content<[Mastodon.Entity.Filter]>, Error> {
let authorization = mastodonAuthenticationBox.userAuthorization
let domain = mastodonAuthenticationBox.domain

View File

@ -24,7 +24,7 @@ extension APIService {
/// - Returns: publisher for `Relationship`
func toggleFollow(
for mastodonUser: MastodonUser,
activeMastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox
activeMastodonAuthenticationBox: MastodonAuthenticationBox
) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Relationship>, Error> {
let impactFeedbackGenerator = UIImpactFeedbackGenerator(style: .light)
@ -96,7 +96,7 @@ extension APIService {
// update database local and return follow query update type for remote request
func followUpdateLocal(
mastodonUserObjectID: NSManagedObjectID,
mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox
mastodonAuthenticationBox: MastodonAuthenticationBox
) -> AnyPublisher<(Mastodon.API.Account.FollowQueryType, MastodonUser.ID), Error> {
let domain = mastodonAuthenticationBox.domain
let requestMastodonUserID = mastodonAuthenticationBox.userID
@ -156,7 +156,7 @@ extension APIService {
func followUpdateRemote(
followQueryType: Mastodon.API.Account.FollowQueryType,
mastodonUserID: MastodonUser.ID,
mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox
mastodonAuthenticationBox: MastodonAuthenticationBox
) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Relationship>, Error> {
let domain = mastodonAuthenticationBox.domain
let authorization = mastodonAuthenticationBox.userAuthorization

View File

@ -17,7 +17,7 @@ import MastodonSDK
extension APIService {
func acceptFollowRequest(
mastodonUserID: MastodonUser.ID,
mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox
mastodonAuthenticationBox: MastodonAuthenticationBox
) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Relationship>, Error> {
let domain = mastodonAuthenticationBox.domain
let authorization = mastodonAuthenticationBox.userAuthorization
@ -61,7 +61,7 @@ extension APIService {
func rejectFollowRequest(
mastodonUserID: MastodonUser.ID,
mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox
mastodonAuthenticationBox: MastodonAuthenticationBox
) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Relationship>, Error> {
let domain = mastodonAuthenticationBox.domain
let authorization = mastodonAuthenticationBox.userAuthorization

View File

@ -22,7 +22,7 @@ extension APIService {
limit: Int = onceRequestStatusMaxCount,
local: Bool? = nil,
hashtag: String,
authorizationBox: AuthenticationService.MastodonAuthenticationBox
authorizationBox: MastodonAuthenticationBox
) -> AnyPublisher<Mastodon.Response.Content<[Mastodon.Entity.Status]>, Error> {
let authorization = authorizationBox.userAuthorization
let requestMastodonUserID = authorizationBox.userID

View File

@ -21,7 +21,7 @@ extension APIService {
maxID: Mastodon.Entity.Status.ID? = nil,
limit: Int = onceRequestStatusMaxCount,
local: Bool? = nil,
authorizationBox: AuthenticationService.MastodonAuthenticationBox
authorizationBox: MastodonAuthenticationBox
) -> AnyPublisher<Mastodon.Response.Content<[Mastodon.Entity.Status]>, Error> {
let authorization = authorizationBox.userAuthorization
let requestMastodonUserID = authorizationBox.userID

View File

@ -14,7 +14,7 @@ extension APIService {
func uploadMedia(
domain: String,
query: Mastodon.API.Media.UploadMediaQuery,
mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox,
mastodonAuthenticationBox: MastodonAuthenticationBox,
needsFallback: Bool
) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Attachment>, Error> {
if needsFallback {
@ -27,7 +27,7 @@ extension APIService {
private func uploadMediaV1(
domain: String,
query: Mastodon.API.Media.UploadMediaQuery,
mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox
mastodonAuthenticationBox: MastodonAuthenticationBox
) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Attachment>, Error> {
let authorization = mastodonAuthenticationBox.userAuthorization
@ -42,7 +42,7 @@ extension APIService {
private func uploadMediaV2(
domain: String,
query: Mastodon.API.Media.UploadMediaQuery,
mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox
mastodonAuthenticationBox: MastodonAuthenticationBox
) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Attachment>, Error> {
let authorization = mastodonAuthenticationBox.userAuthorization
@ -54,12 +54,16 @@ extension APIService {
)
.eraseToAnyPublisher()
}
}
extension APIService {
func updateMedia(
domain: String,
attachmentID: Mastodon.Entity.Attachment.ID,
query: Mastodon.API.Media.UpdateMediaQuery,
mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox
mastodonAuthenticationBox: MastodonAuthenticationBox
) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Attachment>, Error> {
let authorization = mastodonAuthenticationBox.userAuthorization

View File

@ -16,7 +16,7 @@ extension APIService {
func toggleMute(
for mastodonUser: MastodonUser,
activeMastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox
activeMastodonAuthenticationBox: MastodonAuthenticationBox
) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Relationship>, Error> {
let impactFeedbackGenerator = UIImpactFeedbackGenerator(style: .light)
let notificationFeedbackGenerator = UINotificationFeedbackGenerator()
@ -86,7 +86,7 @@ extension APIService {
// update database local and return mute query update type for remote request
func muteUpdateLocal(
mastodonUserObjectID: NSManagedObjectID,
mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox
mastodonAuthenticationBox: MastodonAuthenticationBox
) -> AnyPublisher<(Mastodon.API.Account.MuteQueryType, MastodonUser.ID), Error> {
let domain = mastodonAuthenticationBox.domain
let requestMastodonUserID = mastodonAuthenticationBox.userID
@ -132,7 +132,7 @@ extension APIService {
func muteUpdateRemote(
muteQueryType: Mastodon.API.Account.MuteQueryType,
mastodonUserID: MastodonUser.ID,
mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox
mastodonAuthenticationBox: MastodonAuthenticationBox
) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Relationship>, Error> {
let domain = mastodonAuthenticationBox.domain
let authorization = mastodonAuthenticationBox.userAuthorization

View File

@ -16,7 +16,7 @@ extension APIService {
func allNotifications(
domain: String,
query: Mastodon.API.Notifications.Query,
mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox
mastodonAuthenticationBox: MastodonAuthenticationBox
) -> AnyPublisher<Mastodon.Response.Content<[Mastodon.Entity.Notification]>, Error> {
let authorization = mastodonAuthenticationBox.userAuthorization
let userID = mastodonAuthenticationBox.userID
@ -75,7 +75,7 @@ extension APIService {
func notification(
notificationID: Mastodon.Entity.Notification.ID,
mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox
mastodonAuthenticationBox: MastodonAuthenticationBox
) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Notification>, Error> {
let domain = mastodonAuthenticationBox.domain
let authorization = mastodonAuthenticationBox.userAuthorization

View File

@ -19,7 +19,7 @@ extension APIService {
domain: String,
pollID: Mastodon.Entity.Poll.ID,
pollObjectID: NSManagedObjectID,
mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox
mastodonAuthenticationBox: MastodonAuthenticationBox
) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Poll>, Error> {
let authorization = mastodonAuthenticationBox.userAuthorization
let requestMastodonUserID = mastodonAuthenticationBox.userID
@ -143,7 +143,7 @@ extension APIService {
pollID: Mastodon.Entity.Poll.ID,
pollObjectID: NSManagedObjectID,
choices: [Int],
mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox
mastodonAuthenticationBox: MastodonAuthenticationBox
) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Poll>, Error> {
let authorization = mastodonAuthenticationBox.userAuthorization
let requestMastodonUserID = mastodonAuthenticationBox.userID

View File

@ -62,7 +62,7 @@ extension APIService {
func reblog(
statusID: Mastodon.Entity.Status.ID,
reblogKind: Mastodon.API.Reblog.ReblogKind,
mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox
mastodonAuthenticationBox: MastodonAuthenticationBox
) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Status>, Error> {
let domain = mastodonAuthenticationBox.domain
let authorization = mastodonAuthenticationBox.userAuthorization

View File

@ -16,7 +16,7 @@ extension APIService {
func suggestionAccount(
domain: String,
query: Mastodon.API.Suggestions.Query?,
mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox
mastodonAuthenticationBox: MastodonAuthenticationBox
) -> AnyPublisher<Mastodon.Response.Content<[Mastodon.Entity.Account]>, Error> {
let authorization = mastodonAuthenticationBox.userAuthorization
@ -47,7 +47,7 @@ extension APIService {
func suggestionAccountV2(
domain: String,
query: Mastodon.API.Suggestions.Query?,
mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox
mastodonAuthenticationBox: MastodonAuthenticationBox
) -> AnyPublisher<Mastodon.Response.Content<[Mastodon.Entity.V2.SuggestionAccount]>, Error> {
let authorization = mastodonAuthenticationBox.userAuthorization

View File

@ -17,7 +17,7 @@ extension APIService {
func relationship(
domain: String,
accountIDs: [Mastodon.Entity.Account.ID],
authorizationBox: AuthenticationService.MastodonAuthenticationBox
authorizationBox: MastodonAuthenticationBox
) -> AnyPublisher<Mastodon.Response.Content<[Mastodon.Entity.Relationship]>, Error> {
let authorization = authorizationBox.userAuthorization
let requestMastodonUserID = authorizationBox.userID

View File

@ -14,7 +14,7 @@ extension APIService {
func report(
domain: String,
query: Mastodon.API.Reports.FileReportQuery,
mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox
mastodonAuthenticationBox: MastodonAuthenticationBox
) -> AnyPublisher<Mastodon.Response.Content<Bool>, Error> {
let authorization = mastodonAuthenticationBox.userAuthorization

View File

@ -15,7 +15,7 @@ extension APIService {
func search(
domain: String,
query: Mastodon.API.V2.Search.Query,
mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox
mastodonAuthenticationBox: MastodonAuthenticationBox
) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.SearchResult>, Error> {
let authorization = mastodonAuthenticationBox.userAuthorization
let requestMastodonUserID = mastodonAuthenticationBox.userID

View File

@ -0,0 +1,60 @@
//
// APIService+Status+Publish.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-7-20.
//
import Foundation
import Combine
import CoreData
import CoreDataStack
import CommonOSLog
import MastodonSDK
extension APIService {
func publishStatus(
domain: String,
query: Mastodon.API.Statuses.PublishStatusQuery,
mastodonAuthenticationBox: MastodonAuthenticationBox
) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Status>, Error> {
let authorization = mastodonAuthenticationBox.userAuthorization
return Mastodon.API.Statuses.publishStatus(
session: session,
domain: domain,
query: query,
authorization: authorization
)
.flatMap { response -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Status>, Error> in
#if APP_EXTENSION
return Just(response)
.setFailureType(to: Error.self)
.eraseToAnyPublisher()
#else
return APIService.Persist.persistStatus(
managedObjectContext: self.backgroundManagedObjectContext,
domain: domain,
query: nil,
response: response.map { [$0] },
persistType: .lookUp,
requestMastodonUserID: nil,
log: OSLog.api
)
.setFailureType(to: Error.self)
.tryMap { result -> Mastodon.Response.Content<Mastodon.Entity.Status> in
switch result {
case .success:
return response
case .failure(let error):
throw error
}
}
.eraseToAnyPublisher()
#endif
}
.eraseToAnyPublisher()
}
}

View File

@ -14,48 +14,11 @@ import DateToolsSwift
import MastodonSDK
extension APIService {
func publishStatus(
domain: String,
query: Mastodon.API.Statuses.PublishStatusQuery,
mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox
) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Status>, Error> {
let authorization = mastodonAuthenticationBox.userAuthorization
return Mastodon.API.Statuses.publishStatus(
session: session,
domain: domain,
query: query,
authorization: authorization
)
.flatMap { response -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Status>, Error> in
return APIService.Persist.persistStatus(
managedObjectContext: self.backgroundManagedObjectContext,
domain: domain,
query: nil,
response: response.map { [$0] },
persistType: .lookUp,
requestMastodonUserID: nil,
log: OSLog.api
)
.setFailureType(to: Error.self)
.tryMap { result -> Mastodon.Response.Content<Mastodon.Entity.Status> in
switch result {
case .success:
return response
case .failure(let error):
throw error
}
}
.eraseToAnyPublisher()
}
.eraseToAnyPublisher()
}
func status(
domain: String,
statusID: Mastodon.Entity.Status.ID,
authorizationBox: AuthenticationService.MastodonAuthenticationBox
authorizationBox: MastodonAuthenticationBox
) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Status>, Error> {
let authorization = authorizationBox.userAuthorization
return Mastodon.API.Statuses.status(
@ -91,7 +54,7 @@ extension APIService {
func deleteStatus(
domain: String,
statusID: Mastodon.Entity.Status.ID,
authorizationBox: AuthenticationService.MastodonAuthenticationBox
authorizationBox: MastodonAuthenticationBox
) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Status>, Error> {
let authorization = authorizationBox.userAuthorization
let query = Mastodon.API.Statuses.DeleteStatusQuery(id: statusID)

View File

@ -17,7 +17,7 @@ extension APIService {
func createSubscription(
subscriptionObjectID: NSManagedObjectID,
query: Mastodon.API.Subscriptions.CreateSubscriptionQuery,
mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox
mastodonAuthenticationBox: MastodonAuthenticationBox
) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Subscription>, Error> {
let authorization = mastodonAuthenticationBox.userAuthorization
let domain = mastodonAuthenticationBox.domain
@ -50,7 +50,7 @@ extension APIService {
}
func cancelSubscription(
mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox
mastodonAuthenticationBox: MastodonAuthenticationBox
) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.EmptySubscription>, Error> {
let authorization = mastodonAuthenticationBox.userAuthorization
let domain = mastodonAuthenticationBox.domain

View File

@ -17,7 +17,7 @@ extension APIService {
func statusContext(
domain: String,
statusID: Mastodon.Entity.Status.ID,
mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox
mastodonAuthenticationBox: MastodonAuthenticationBox
) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Context>, Error> {
let authorization = mastodonAuthenticationBox.userAuthorization
guard domain == mastodonAuthenticationBox.domain else {

View File

@ -23,7 +23,7 @@ extension APIService {
excludeReplies: Bool? = nil,
excludeReblogs: Bool? = nil,
onlyMedia: Bool? = nil,
authorizationBox: AuthenticationService.MastodonAuthenticationBox
authorizationBox: MastodonAuthenticationBox
) -> AnyPublisher<Mastodon.Response.Content<[Mastodon.Entity.Status]>, Error> {
let authorization = authorizationBox.userAuthorization
let requestMastodonUserID = authorizationBox.userID

View File

@ -21,7 +21,6 @@ final class APIService {
// internal
let session: URLSession
// input
let backgroundManagedObjectContext: NSManagedObjectContext

View File

@ -24,9 +24,9 @@ final class AuthenticationService: NSObject {
// output
let mastodonAuthentications = CurrentValueSubject<[MastodonAuthentication], Never>([])
let mastodonAuthenticationBoxes = CurrentValueSubject<[AuthenticationService.MastodonAuthenticationBox], Never>([])
let mastodonAuthenticationBoxes = CurrentValueSubject<[MastodonAuthenticationBox], Never>([])
let activeMastodonAuthentication = CurrentValueSubject<MastodonAuthentication?, Never>(nil)
let activeMastodonAuthenticationBox = CurrentValueSubject<AuthenticationService.MastodonAuthenticationBox?, Never>(nil)
let activeMastodonAuthenticationBox = CurrentValueSubject<MastodonAuthenticationBox?, Never>(nil)
init(
managedObjectContext: NSManagedObjectContext,
@ -61,11 +61,11 @@ final class AuthenticationService: NSObject {
.store(in: &disposeBag)
mastodonAuthentications
.map { authentications -> [AuthenticationService.MastodonAuthenticationBox] in
.map { authentications -> [MastodonAuthenticationBox] in
return authentications
.sorted(by: { $0.activedAt > $1.activedAt })
.compactMap { authentication -> AuthenticationService.MastodonAuthenticationBox? in
return AuthenticationService.MastodonAuthenticationBox(
.compactMap { authentication -> MastodonAuthenticationBox? in
return MastodonAuthenticationBox(
domain: authentication.domain,
userID: authentication.userID,
appAuthorization: Mastodon.API.OAuth.Authorization(accessToken: authentication.appAccessToken),
@ -91,15 +91,6 @@ final class AuthenticationService: NSObject {
}
extension AuthenticationService {
struct MastodonAuthenticationBox {
let domain: String
let userID: MastodonUser.ID
let appAuthorization: Mastodon.API.OAuth.Authorization
let userAuthorization: Mastodon.API.OAuth.Authorization
}
}
extension AuthenticationService {
func activeMastodonUser(domain: String, userID: MastodonUser.ID) -> AnyPublisher<Result<Bool, Error>, Never> {
@ -133,7 +124,7 @@ extension AuthenticationService {
guard let mastodonAuthentication = try? managedObjectContext.fetch(request).first else {
return
}
_mastodonAuthenticationBox = AuthenticationService.MastodonAuthenticationBox(
_mastodonAuthenticationBox = MastodonAuthenticationBox(
domain: mastodonAuthentication.domain,
userID: mastodonAuthentication.userID,
appAuthorization: Mastodon.API.OAuth.Authorization(accessToken: mastodonAuthentication.appAccessToken),

View File

@ -83,7 +83,7 @@ extension MastodonAttachmentService.UploadState {
{
self.needsFallback = true
stateMachine.enter(Uploading.self)
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: upload attachment fallback to V1", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription)
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: upload attachment fallback to V1", ((#file as NSString).lastPathComponent), #line, #function)
} else {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: upload attachment fail: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription)
service.error.send(error)

View File

@ -27,7 +27,7 @@ final class MastodonAttachmentService {
// input
let context: AppContext
var authenticationBox: AuthenticationService.MastodonAuthenticationBox?
var authenticationBox: MastodonAuthenticationBox?
let file = CurrentValueSubject<Mastodon.Query.MediaAttachment?, Never>(nil)
let description = CurrentValueSubject<String?, Never>(nil)
@ -52,7 +52,7 @@ final class MastodonAttachmentService {
init(
context: AppContext,
pickerResult: PHPickerResult,
initialAuthenticationBox: AuthenticationService.MastodonAuthenticationBox?
initialAuthenticationBox: MastodonAuthenticationBox?
) {
self.context = context
self.authenticationBox = initialAuthenticationBox
@ -90,7 +90,7 @@ final class MastodonAttachmentService {
init(
context: AppContext,
image: UIImage,
initialAuthenticationBox: AuthenticationService.MastodonAuthenticationBox?
initialAuthenticationBox: MastodonAuthenticationBox?
) {
self.context = context
self.authenticationBox = initialAuthenticationBox
@ -105,7 +105,7 @@ final class MastodonAttachmentService {
init(
context: AppContext,
documentURL: URL,
initialAuthenticationBox: AuthenticationService.MastodonAuthenticationBox?
initialAuthenticationBox: MastodonAuthenticationBox?
) {
self.context = context
self.authenticationBox = initialAuthenticationBox
@ -191,7 +191,7 @@ extension MastodonAttachmentService {
extension MastodonAttachmentService {
// FIXME: needs reset state for multiple account posting support
func uploading(mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox) -> Bool {
func uploading(mastodonAuthenticationBox: MastodonAuthenticationBox) -> Bool {
authenticationBox = mastodonAuthenticationBox
return uploadStateMachine.enter(UploadState.self)
}

View File

@ -82,7 +82,7 @@ extension NotificationService {
extension NotificationService {
func dequeueNotificationViewModel(
mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox
mastodonAuthenticationBox: MastodonAuthenticationBox
) -> NotificationViewModel? {
var _notificationSubscription: NotificationViewModel?
workingQueue.sync {
@ -130,7 +130,7 @@ extension NotificationService {
// cancel subscription if sign-out
let accessToken = mastodonPushNotification.accessToken
let mastodonAuthenticationBox = AuthenticationService.MastodonAuthenticationBox(
let mastodonAuthenticationBox = MastodonAuthenticationBox(
domain: domain,
userID: userID,
appAuthorization: .init(accessToken: accessToken),
@ -178,7 +178,7 @@ extension NotificationService.NotificationViewModel {
func createSubscribeQuery(
deviceToken: Data,
queryData: Mastodon.API.Subscriptions.QueryData,
mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox
mastodonAuthenticationBox: MastodonAuthenticationBox
) -> Mastodon.API.Subscriptions.CreateSubscriptionQuery {
let deviceToken = [UInt8](deviceToken).toHexString()

View File

@ -41,7 +41,7 @@ final class SettingService {
// create setting (if non-exist) for authenticated users
authenticationService.mastodonAuthenticationBoxes
.compactMap { [weak self] mastodonAuthenticationBoxes -> AnyPublisher<[AuthenticationService.MastodonAuthenticationBox], Never>? in
.compactMap { [weak self] mastodonAuthenticationBoxes -> AnyPublisher<[MastodonAuthenticationBox], Never>? in
guard let self = self else { return nil }
guard let authenticationService = self.authenticationService else { return nil }
guard let activeMastodonAuthenticationBox = mastodonAuthenticationBoxes.first else { return nil }

View File

@ -104,7 +104,7 @@ extension StatusPrefetchingService {
statusObjectID: NSManagedObjectID,
statusID: Mastodon.Entity.Status.ID,
replyToStatusID: Mastodon.Entity.Status.ID,
authorizationBox: AuthenticationService.MastodonAuthenticationBox
authorizationBox: MastodonAuthenticationBox
) {
workingQueue.async { [weak self] in
guard let self = self, let apiService = self.apiService else { return }

View File

@ -0,0 +1,42 @@
//
// CGImage.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-3-31.
//
import CoreImage
extension CGImage {
// Reference
// https://www.itu.int/dms_pubrec/itu-r/rec/bt/R-REC-BT.709-6-201506-I!!PDF-E.pdf
// Luma Y = 0.2126R + 0.7152G + 0.0722B
public var brightness: CGFloat? {
let context = CIContext() // default with metal accelerate
let ciImage = CIImage(cgImage: self)
let rec709Image = context.createCGImage(
ciImage,
from: ciImage.extent,
format: .RGBA8,
colorSpace: CGColorSpace(name: CGColorSpace.itur_709) // BT.709 a.k.a Rec.709
)
guard let image = rec709Image,
image.bitsPerPixel == 32,
let data = rec709Image?.dataProvider?.data,
let pointer = CFDataGetBytePtr(data) else { return nil }
let length = CFDataGetLength(data)
guard length > 0 else { return nil }
var luma: CGFloat = 0.0
for i in stride(from: 0, to: length, by: 4) {
let r = pointer[i]
let g = pointer[i + 1]
let b = pointer[i + 2]
let Y = 0.2126 * CGFloat(r) + 0.7152 * CGFloat(g) + 0.0722 * CGFloat(b)
luma += Y
}
luma /= CGFloat(width * height)
return luma
}
}

View File

@ -1,10 +1,12 @@
//
// UIImage.swift
//
// Mastodon
//
// Created by MainasuK Cirno on 2021-7-16.
// Created by sxiaojian on 2021/3/8.
//
import CoreImage
import CoreImage.CIFilterBuiltins
import UIKit
extension UIImage {
@ -17,3 +19,74 @@ extension UIImage {
}
}
}
// refs: https://www.hackingwithswift.com/example-code/media/how-to-read-the-average-color-of-a-uiimage-using-ciareaaverage
extension UIImage {
@available(iOS 14.0, *)
public var dominantColor: UIColor? {
guard let inputImage = CIImage(image: self) else { return nil }
let filter = CIFilter.areaAverage()
filter.inputImage = inputImage
filter.extent = inputImage.extent
guard let outputImage = filter.outputImage else { return nil }
var bitmap = [UInt8](repeating: 0, count: 4)
let context = CIContext(options: [.workingColorSpace: kCFNull])
context.render(outputImage, toBitmap: &bitmap, rowBytes: 4, bounds: CGRect(x: 0, y: 0, width: 1, height: 1), format: .RGBA8, colorSpace: nil)
return UIColor(red: CGFloat(bitmap[0]) / 255, green: CGFloat(bitmap[1]) / 255, blue: CGFloat(bitmap[2]) / 255, alpha: CGFloat(bitmap[3]) / 255)
}
}
extension UIImage {
public var domainLumaCoefficientsStyle: UIUserInterfaceStyle? {
guard let brightness = cgImage?.brightness else { return nil }
return brightness > 100 ? .light : .dark // 0 ~ 255
}
}
extension UIImage {
public func blur(radius: CGFloat) -> UIImage? {
guard let inputImage = CIImage(image: self) else { return nil }
let blurFilter = CIFilter.gaussianBlur()
blurFilter.inputImage = inputImage
blurFilter.radius = Float(radius)
guard let outputImage = blurFilter.outputImage else { return nil }
guard let cgImage = CIContext().createCGImage(outputImage, from: outputImage.extent) else { return nil }
let image = UIImage(cgImage: cgImage, scale: scale, orientation: imageOrientation)
return image
}
}
extension UIImage {
public func withRoundedCorners(radius: CGFloat? = nil) -> UIImage? {
let maxRadius = min(size.width, size.height) / 2
let cornerRadius: CGFloat = {
guard let radius = radius, radius > 0 else { return maxRadius }
return min(radius, maxRadius)
}()
let render = UIGraphicsImageRenderer(size: size)
return render.image { (_: UIGraphicsImageRendererContext) in
let rect = CGRect(origin: .zero, size: size)
UIBezierPath(roundedRect: rect, cornerRadius: cornerRadius).addClip()
draw(in: rect)
}
}
}
extension UIImage {
public static func adaptiveUserInterfaceStyleImage(lightImage: UIImage, darkImage: UIImage) -> UIImage {
let imageAsset = UIImageAsset()
imageAsset.register(lightImage, with: UITraitCollection(traitsFrom: [
UITraitCollection(displayScale: 1.0),
UITraitCollection(userInterfaceStyle: .light)
]))
imageAsset.register(darkImage, with: UITraitCollection(traitsFrom: [
UITraitCollection(displayScale: 1.0),
UITraitCollection(userInterfaceStyle: .dark)
]))
return imageAsset.image(with: UITraitCollection.current)
}
}

View File

@ -41,11 +41,17 @@ extension ItemProviderLoader {
guard let source = CGImageSourceCreateWithURL(url as CFURL, sourceOptions) else {
return
}
#if APP_EXTENSION
let maxPixelSize: Int = 4096 // not limit but may upload fail
#else
let maxPixelSize: Int = 1536 // fit 120MB RAM limit
#endif
let downsampleOptions = [
kCGImageSourceCreateThumbnailFromImageAlways: true,
kCGImageSourceCreateThumbnailWithTransform: true,
kCGImageSourceThumbnailMaxPixelSize: 4096,
kCGImageSourceThumbnailMaxPixelSize: maxPixelSize,
] as CFDictionary
guard let cgImage = CGImageSourceCreateThumbnailAtIndex(source, 0, downsampleOptions) else {

View File

@ -34,8 +34,7 @@ class ShareViewController: UIViewController {
private(set) lazy var cancelBarButtonItem = UIBarButtonItem(title: L10n.Common.Controls.Actions.cancel, style: .plain, target: self, action: #selector(ShareViewController.cancelBarButtonItemPressed(_:)))
private(set) lazy var publishBarButtonItem: UIBarButtonItem = {
let barButtonItem = UIBarButtonItem(customView: publishButton)
barButtonItem.target = self
barButtonItem.action = #selector(ShareViewController.publishBarButtonItemPressed(_:))
publishButton.addTarget(self, action: #selector(ShareViewController.publishBarButtonItemPressed(_:)), for: .touchUpInside)
return barButtonItem
}()
@ -173,6 +172,12 @@ extension ShareViewController {
}
}
.store(in: &disposeBag)
// bind valid
viewModel.isValid
.receive(on: DispatchQueue.main)
.assign(to: \.isEnabled, on: publishButton)
.store(in: &disposeBag)
}
override func viewDidAppear(_ animated: Bool) {
@ -180,6 +185,8 @@ extension ShareViewController {
viewModel.viewDidAppear.value = true
viewModel.inputItems.value = extensionContext?.inputItems.compactMap { $0 as? NSExtensionItem } ?? []
viewModel.composeViewModel.viewDidAppear = true
}
override func viewSafeAreaInsetsDidChange() {
@ -204,6 +211,42 @@ extension ShareViewController {
@objc private func publishBarButtonItemPressed(_ sender: UIBarButtonItem) {
logger.debug("\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public)")
viewModel.isPublishing.value = true
viewModel.publish()
.delay(for: 2, scheduler: DispatchQueue.main)
.receive(on: DispatchQueue.main)
.sink { [weak self] completion in
guard let self = self else { return }
self.viewModel.isPublishing.value = false
switch completion {
case .failure:
let alertController = UIAlertController(
title: L10n.Common.Alerts.PublishPostFailure.title,
message: L10n.Common.Alerts.PublishPostFailure.message,
preferredStyle: .actionSheet // can not use alert in extension
)
let okAction = UIAlertAction(
title: L10n.Common.Controls.Actions.ok,
style: .cancel,
handler: nil
)
alertController.addAction(okAction)
self.present(alertController, animated: true, completion: nil)
case .finished:
self.publishButton.setTitle(L10n.Common.Controls.Actions.done, for: .normal)
self.publishButton.isUserInteractionEnabled = false
DispatchQueue.main.asyncAfter(deadline: .now() + 1) { [weak self] in
guard let self = self else { return }
self.extensionContext?.completeRequest(returningItems: nil, completionHandler: nil)
}
}
} receiveValue: { response in
// do nothing
}
.store(in: &disposeBag)
}
}

View File

@ -10,6 +10,7 @@ import Foundation
import Combine
import CoreData
import CoreDataStack
import MastodonSDK
import MastodonUI
import SwiftUI
import UniformTypeIdentifiers
@ -33,6 +34,7 @@ final class ShareViewModel {
// output
let authentication = CurrentValueSubject<Result<MastodonAuthentication, Error>?, Never>(nil)
let isFetchAuthentication = CurrentValueSubject<Bool, Never>(true)
let isPublishing = CurrentValueSubject<Bool, Never>(false)
let isBusy = CurrentValueSubject<Bool, Never>(true)
let isValid = CurrentValueSubject<Bool, Never>(false)
let composeViewModel = ComposeViewModel()
@ -59,11 +61,13 @@ final class ShareViewModel {
}
.store(in: &disposeBag)
// bind authentication loading state
authentication
.map { result in result == nil }
.assign(to: \.value, on: isFetchAuthentication)
.store(in: &disposeBag)
// bind user locked state
authentication
.compactMap { result -> Bool? in
guard let result = result else { return nil }
@ -80,15 +84,105 @@ final class ShareViewModel {
.assign(to: \.value, on: selectedStatusVisibility)
.store(in: &disposeBag)
isFetchAuthentication
// bind author
authentication
.receive(on: DispatchQueue.main)
.assign(to: \.value, on: isBusy)
.sink { [weak self] result in
guard let self = self else { return }
guard let result = result else { return }
switch result {
case .success(let authentication):
self.composeViewModel.avatarImageURL = authentication.user.avatarImageURL()
self.composeViewModel.authorName = authentication.user.displayNameWithFallback
self.composeViewModel.authorUsername = "@" + authentication.user.username
case .failure:
self.composeViewModel.avatarImageURL = nil
self.composeViewModel.authorName = " "
self.composeViewModel.authorUsername = " "
}
}
.store(in: &disposeBag)
// bind authentication to compose view model
authentication
.map { result -> MastodonAuthentication? in
guard let result = result else { return nil }
switch result {
case .success(let authentication):
return authentication
case .failure:
return nil
}
}
.assign(to: &composeViewModel.$authentication)
// bind isBusy
Publishers.CombineLatest(
isFetchAuthentication,
isPublishing
)
.receive(on: DispatchQueue.main)
.map { $0 || $1 }
.assign(to: \.value, on: isBusy)
.store(in: &disposeBag)
// pass initial i18n string
composeViewModel.statusPlaceholder = L10n.Scene.Compose.contentInputPlaceholder
composeViewModel.contentWarningPlaceholder = L10n.Scene.Compose.ContentWarning.placeholder
composeViewModel.toolbarHeight = ComposeToolbarView.toolbarHeight
// bind compose bar button item UI state
let isComposeContentEmpty = composeViewModel.$statusContent
.map { $0.isEmpty }
let isComposeContentValid = composeViewModel.$characterCount
.map { characterCount -> Bool in
return characterCount <= ShareViewModel.composeContentLimit
}
let isMediaEmpty = composeViewModel.$attachmentViewModels
.map { $0.isEmpty }
let isMediaUploadAllSuccess = composeViewModel.$attachmentViewModels
.map { viewModels in
viewModels.allSatisfy { $0.uploadStateMachineSubject.value is StatusAttachmentViewModel.UploadState.Finish }
}
let isPublishBarButtonItemEnabledPrecondition1 = Publishers.CombineLatest4(
isComposeContentEmpty,
isComposeContentValid,
isMediaEmpty,
isMediaUploadAllSuccess
)
.map { isComposeContentEmpty, isComposeContentValid, isMediaEmpty, isMediaUploadAllSuccess -> Bool in
if isMediaEmpty {
return isComposeContentValid && !isComposeContentEmpty
} else {
return isComposeContentValid && isMediaUploadAllSuccess
}
}
.eraseToAnyPublisher()
let isPublishBarButtonItemEnabledPrecondition2 = Publishers.CombineLatest(
isComposeContentEmpty,
isComposeContentValid
)
.map { isComposeContentEmpty, isComposeContentValid -> Bool in
return isComposeContentValid && !isComposeContentEmpty
}
.eraseToAnyPublisher()
Publishers.CombineLatest(
isPublishBarButtonItemEnabledPrecondition1,
isPublishBarButtonItemEnabledPrecondition2
)
.map { $0 && $1 }
.assign(to: \.value, on: isValid)
.store(in: &disposeBag)
// bind counter
composeViewModel.$characterCount
.assign(to: \.value, on: characterCount)
.store(in: &disposeBag)
// setup theme
setupBackgroundColor(theme: ThemeService.shared.currentTheme.value)
ThemeService.shared.currentTheme
.receive(on: DispatchQueue.main)
@ -97,10 +191,6 @@ final class ShareViewModel {
self.setupBackgroundColor(theme: theme)
}
.store(in: &disposeBag)
composeViewModel.$characterCount
.assign(to: \.value, on: characterCount)
.store(in: &disposeBag)
}
private func setupBackgroundColor(theme: Theme) {
@ -184,3 +274,76 @@ extension ShareViewModel {
}
}
}
extension ShareViewModel {
func publish() -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Status>, Error> {
guard let authentication = composeViewModel.authentication else {
return Fail(error: APIService.APIError.implicit(.authenticationMissing)).eraseToAnyPublisher()
}
let mastodonAuthenticationBox = MastodonAuthenticationBox(
domain: authentication.domain,
userID: authentication.userID,
appAuthorization: Mastodon.API.OAuth.Authorization(accessToken: authentication.appAccessToken),
userAuthorization: Mastodon.API.OAuth.Authorization(accessToken: authentication.userAccessToken)
)
let domain = authentication.domain
let attachmentViewModels = composeViewModel.attachmentViewModels
let mediaIDs = attachmentViewModels.compactMap { viewModel in
viewModel.attachment.value?.id
}
let sensitive: Bool = composeViewModel.isContentWarningComposing
let spoilerText: String? = {
let text = composeViewModel.contentWarningContent
guard !text.isEmpty else { return nil }
return text
}()
let visibility = selectedStatusVisibility.value.visibility
let updateMediaQuerySubscriptions: [AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Attachment>, Error>] = {
var subscriptions: [AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Attachment>, Error>] = []
for attachmentViewModel in attachmentViewModels {
guard let attachmentID = attachmentViewModel.attachment.value?.id else { continue }
let description = attachmentViewModel.descriptionContent.trimmingCharacters(in: .whitespacesAndNewlines)
guard !description.isEmpty else { continue }
let query = Mastodon.API.Media.UpdateMediaQuery(
file: nil,
thumbnail: nil,
description: description,
focus: nil
)
let subscription = APIService.shared.updateMedia(
domain: domain,
attachmentID: attachmentID,
query: query,
mastodonAuthenticationBox: mastodonAuthenticationBox
)
subscriptions.append(subscription)
}
return subscriptions
}()
let status = composeViewModel.statusContent
return Publishers.MergeMany(updateMediaQuerySubscriptions)
.collect()
.flatMap { attachments -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Status>, Error> in
let query = Mastodon.API.Statuses.PublishStatusQuery(
status: status,
mediaIDs: mediaIDs.isEmpty ? nil : mediaIDs,
pollOptions: nil,
pollExpiresIn: nil,
inReplyToID: nil,
sensitive: sensitive,
spoilerText: spoilerText,
visibility: visibility
)
return APIService.shared.publishStatus(
domain: domain,
query: query,
mastodonAuthenticationBox: mastodonAuthenticationBox
)
}
.eraseToAnyPublisher()
}
}

View File

@ -47,7 +47,8 @@ public struct ComposeView: View {
placeholder: viewModel.statusPlaceholder,
width: statusEditorViewWidth,
attributedString: viewModel.statusContentAttributedString,
keyboardType: .twitter
keyboardType: .twitter,
viewDidAppear: $viewModel.viewDidAppear
)
.frame(width: statusEditorViewWidth)
.frame(minHeight: 100)
@ -55,11 +56,23 @@ public struct ComposeView: View {
.listRow()
// Attachments
ForEach(viewModel.attachmentViewModels) { viewModel in
ForEach(viewModel.attachmentViewModels) { attachmentViewModel in
let descriptionBinding = Binding {
return attachmentViewModel.descriptionContent
} set: { newValue in
attachmentViewModel.descriptionContent = newValue
}
StatusAttachmentView(
image: viewModel.thumbnailImage,
image: attachmentViewModel.thumbnailImage,
descriptionPlaceholder: attachmentViewModel.descriptionPlaceholder,
description: descriptionBinding,
errorPrompt: attachmentViewModel.errorPrompt,
errorPromptImage: attachmentViewModel.errorPromptImage,
isUploading: attachmentViewModel.isUploading,
progressViewTintColor: attachmentViewModel.progressViewTintColor,
removeButtonAction: {
self.viewModel.removeAttachmentViewModel(viewModel)
self.viewModel.removeAttachmentViewModel(attachmentViewModel)
}
)
}
@ -73,7 +86,7 @@ public struct ComposeView: View {
.listRow()
} // end List
.introspectTableView(customize: { tableView in
tableView.keyboardDismissMode = .onDrag
// tableView.keyboardDismissMode = .onDrag
tableView.verticalScrollIndicatorInsets.bottom = viewModel.toolbarHeight
})
.preference(

View File

@ -8,12 +8,16 @@
import Foundation
import SwiftUI
import Combine
import CoreDataStack
class ComposeViewModel: ObservableObject {
var disposeBag = Set<AnyCancellable>()
@Published var authentication: MastodonAuthentication?
@Published var toolbarHeight: CGFloat = 0
@Published var viewDidAppear = false
@Published var avatarImageURL: URL?
@Published var authorName: String = ""
@ -51,10 +55,38 @@ class ComposeViewModel: ObservableObject {
}
.assign(to: &$characterCount)
// setup attribute updater
$attachmentViewModels
.receive(on: DispatchQueue.main)
.debounce(for: 0.3, scheduler: DispatchQueue.main)
.sink { attachmentViewModels in
// drive upload state
// make image upload in the queue
for attachmentViewModel in attachmentViewModels {
// skip when prefix N task when task finish OR fail OR uploading
guard let currentState = attachmentViewModel.uploadStateMachine.currentState else { break }
if currentState is StatusAttachmentViewModel.UploadState.Fail {
continue
}
if currentState is StatusAttachmentViewModel.UploadState.Finish {
continue
}
if currentState is StatusAttachmentViewModel.UploadState.Uploading {
break
}
// trigger uploading one by one
if currentState is StatusAttachmentViewModel.UploadState.Initial {
attachmentViewModel.uploadStateMachine.enter(StatusAttachmentViewModel.UploadState.Uploading.self)
break
}
}
}
.store(in: &disposeBag)
#if DEBUG
avatarImageURL = URL(string: "https://upload.wikimedia.org/wikipedia/commons/2/2c/Rotating_earth_%28large%29.gif")
authorName = "Alice"
authorUsername = "alice"
// avatarImageURL = URL(string: "https://upload.wikimedia.org/wikipedia/commons/2/2c/Rotating_earth_%28large%29.gif")
// authorName = "Alice"
// authorUsername = "alice"
#endif
}
@ -64,11 +96,18 @@ extension ComposeViewModel {
func setupAttachmentViewModels(_ viewModels: [StatusAttachmentViewModel]) {
attachmentViewModels = viewModels
for viewModel in viewModels {
// set delegate
viewModel.delegate = self
// set observed
viewModel.objectWillChange.sink { [weak self] _ in
guard let self = self else { return }
self.objectWillChange.send()
}
.store(in: &viewModel.disposeBag)
// bind authentication
$authentication
.assign(to: \.value, on: viewModel.authentication)
.store(in: &viewModel.disposeBag)
}
}
@ -78,3 +117,13 @@ extension ComposeViewModel {
}
}
}
// MARK: - StatusAttachmentViewModelDelegate
extension ComposeViewModel: StatusAttachmentViewModelDelegate {
func statusAttachmentViewModel(_ viewModel: StatusAttachmentViewModel, uploadStateDidChange state: StatusAttachmentViewModel.UploadState?) {
// trigger event update
DispatchQueue.main.async {
self.attachmentViewModels = self.attachmentViewModels
}
}
}

View File

@ -6,33 +6,74 @@
//
import SwiftUI
import Introspect
struct StatusAttachmentView: View {
let image: UIImage?
let descriptionPlaceholder: String
@Binding var description: String
let errorPrompt: String?
let errorPromptImage: UIImage
let isUploading: Bool
let progressViewTintColor: UIColor
let removeButtonAction: () -> Void
var body: some View {
let image = image ?? UIImage.placeholder(color: .systemFill)
Color.clear
.aspectRatio(CGSize(width: 16, height: 9), contentMode: .fill)
.overlay(
Image(uiImage: image)
.resizable()
.aspectRatio(contentMode: .fill)
)
.background(Color.gray)
.cornerRadius(4)
.badgeView(
Button(action: {
removeButtonAction()
}, label: {
Image(systemName: "minus.circle.fill")
.renderingMode(.original)
.font(.system(size: 22, weight: .bold, design: .default))
})
.buttonStyle(BorderlessButtonStyle())
)
ZStack(alignment: .bottom) {
if let errorPrompt = errorPrompt {
Color.clear
.aspectRatio(CGSize(width: 16, height: 9), contentMode: .fill)
.overlay(
VStack(alignment: .center) {
Image(uiImage: errorPromptImage)
Text(errorPrompt)
.lineLimit(2)
}
)
.background(Color.gray)
} else {
Color.clear
.aspectRatio(CGSize(width: 16, height: 9), contentMode: .fill)
.overlay(
Image(uiImage: image)
.resizable()
.aspectRatio(contentMode: .fill)
)
.background(Color.gray)
LinearGradient(gradient: Gradient(colors: [Color(white: 0, opacity: 0.69), Color.clear]), startPoint: .bottom, endPoint: .top)
.frame(maxHeight: 71)
TextField("", text: $description)
.placeholder(when: description.isEmpty) {
Text(descriptionPlaceholder).foregroundColor(Color(white: 1, opacity: 0.6))
.lineLimit(1)
}
.foregroundColor(.white)
.font(.system(size: 15, weight: .regular, design: .default))
.padding(EdgeInsets(top: 0, leading: 8, bottom: 7, trailing: 8))
}
}
.cornerRadius(4)
.badgeView(
Button(action: {
removeButtonAction()
}, label: {
Image(systemName: "minus.circle.fill")
.renderingMode(.original)
.font(.system(size: 22, weight: .bold, design: .default))
})
.buttonStyle(BorderlessButtonStyle())
)
.overlay(
Group {
if isUploading {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: Color(progressViewTintColor)))
}
}
)
}
}
@ -49,12 +90,32 @@ extension View {
}
}
/// ref: https://stackoverflow.com/a/57715771/3797903
extension View {
func placeholder<Content: View>(
when shouldShow: Bool,
alignment: Alignment = .leading,
@ViewBuilder placeholder: () -> Content) -> some View {
ZStack(alignment: alignment) {
placeholder().opacity(shouldShow ? 1 : 0)
self
}
}
}
struct StatusAttachmentView_Previews: PreviewProvider {
static var previews: some View {
ScrollView {
StatusAttachmentView(
image: UIImage(systemName: "photo"),
descriptionPlaceholder: "Describe photo",
description: .constant(""),
errorPrompt: nil,
errorPromptImage: StatusAttachmentViewModel.photoFillSplitImage,
isUploading: true,
progressViewTintColor: .systemFill,
removeButtonAction: {
// do nothing
}

View File

@ -0,0 +1,129 @@
//
// StatusAttachmentViewModel+UploadState.swift
// ShareActionExtension
//
// Created by MainasuK Cirno on 2021-7-20.
//
import os.log
import Foundation
import Combine
import GameplayKit
import MastodonSDK
extension StatusAttachmentViewModel {
class UploadState: GKState {
weak var viewModel: StatusAttachmentViewModel?
init(viewModel: StatusAttachmentViewModel) {
self.viewModel = viewModel
}
override func didEnter(from previousState: GKState?) {
os_log("%{public}s[%{public}ld], %{public}s: enter %s, previous: %s", ((#file as NSString).lastPathComponent), #line, #function, self.debugDescription, previousState.debugDescription)
viewModel?.uploadStateMachineSubject.send(self)
}
}
}
extension StatusAttachmentViewModel.UploadState {
class Initial: StatusAttachmentViewModel.UploadState {
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
guard viewModel?.authentication.value != nil else { return false }
if stateClass == Initial.self {
return true
}
if viewModel?.file.value != nil {
return stateClass == Uploading.self
} else {
return stateClass == Fail.self
}
}
}
class Uploading: StatusAttachmentViewModel.UploadState {
let logger = Logger(subsystem: "StatusAttachmentViewModel.UploadState.Uploading", category: "logic")
var needsFallback = false
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
return stateClass == Fail.self || stateClass == Finish.self || stateClass == Uploading.self
}
override func didEnter(from previousState: GKState?) {
super.didEnter(from: previousState)
guard let viewModel = viewModel, let stateMachine = stateMachine else { return }
guard let authentication = viewModel.authentication.value else { return }
guard let file = viewModel.file.value else { return }
let description = viewModel.descriptionContent
let query = Mastodon.API.Media.UploadMediaQuery(
file: file,
thumbnail: nil,
description: description,
focus: nil
)
let mastodonAuthenticationBox = MastodonAuthenticationBox(
domain: authentication.domain,
userID: authentication.userID,
appAuthorization: Mastodon.API.OAuth.Authorization(accessToken: authentication.appAccessToken),
userAuthorization: Mastodon.API.OAuth.Authorization(accessToken: authentication.userAccessToken)
)
// and needs clone the `query` if needs retry
APIService.shared.uploadMedia(
domain: mastodonAuthenticationBox.domain,
query: query,
mastodonAuthenticationBox: mastodonAuthenticationBox,
needsFallback: needsFallback
)
.receive(on: DispatchQueue.main)
.sink { [weak self] completion in
guard let self = self else { return }
switch completion {
case .failure(let error):
if let apiError = error as? Mastodon.API.Error,
apiError.httpResponseStatus == .notFound,
self.needsFallback == false
{
self.needsFallback = true
stateMachine.enter(Uploading.self)
self.logger.debug("\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): fallback to V1")
} else {
self.logger.debug("\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): fail: \(error.localizedDescription)")
viewModel.error = error
stateMachine.enter(Fail.self)
}
case .finished:
self.logger.debug("\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): upload attachment success")
break
}
} receiveValue: { [weak self] response in
guard let self = self else { return }
self.logger.debug("\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): upload attachment \(response.value.id) success, \(response.value.url ?? "<nil>")")
viewModel.attachment.value = response.value
stateMachine.enter(Finish.self)
}
.store(in: &viewModel.disposeBag)
}
}
class Fail: StatusAttachmentViewModel.UploadState {
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
// allow discard publishing
return stateClass == Uploading.self || stateClass == Finish.self
}
}
class Finish: StatusAttachmentViewModel.UploadState {
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
return false
}
}
}

View File

@ -9,16 +9,29 @@ import os.log
import Foundation
import SwiftUI
import Combine
import CoreDataStack
import MastodonSDK
import MastodonUI
import AVFoundation
import GameplayKit
import MobileCoreServices
import UniformTypeIdentifiers
protocol StatusAttachmentViewModelDelegate: AnyObject {
func statusAttachmentViewModel(_ viewModel: StatusAttachmentViewModel, uploadStateDidChange state: StatusAttachmentViewModel.UploadState?)
}
final class StatusAttachmentViewModel: ObservableObject, Identifiable {
static let photoFillSplitImage = Asset.Connectivity.photoFillSplit.image.withRenderingMode(.alwaysTemplate)
static let videoSplashImage: UIImage = {
let image = UIImage(systemName: "video.slash")!.withConfiguration(UIImage.SymbolConfiguration(pointSize: 64))
return image
}()
let logger = Logger(subsystem: "StatusAttachmentViewModel", category: "logic")
weak var delegate: StatusAttachmentViewModelDelegate?
var disposeBag = Set<AnyCancellable>()
let id = UUID()
@ -26,15 +39,36 @@ final class StatusAttachmentViewModel: ObservableObject, Identifiable {
// input
let file = CurrentValueSubject<Mastodon.Query.MediaAttachment?, Never>(nil)
@Published var description = ""
let authentication = CurrentValueSubject<MastodonAuthentication?, Never>(nil)
@Published var descriptionContent = ""
// output
let attachment = CurrentValueSubject<Mastodon.Entity.Attachment?, Never>(nil)
@Published var thumbnailImage: UIImage?
@Published var descriptionPlaceholder = ""
@Published var isUploading = true
@Published var progressViewTintColor = UIColor.systemFill
@Published var error: Error?
@Published var errorPrompt: String?
@Published var errorPromptImage: UIImage = StatusAttachmentViewModel.photoFillSplitImage
private(set) lazy var uploadStateMachine: GKStateMachine = {
// exclude timeline middle fetcher state
let stateMachine = GKStateMachine(states: [
UploadState.Initial(viewModel: self),
UploadState.Uploading(viewModel: self),
UploadState.Fail(viewModel: self),
UploadState.Finish(viewModel: self),
])
stateMachine.enter(UploadState.Initial.self)
return stateMachine
}()
lazy var uploadStateMachineSubject = CurrentValueSubject<StatusAttachmentViewModel.UploadState?, Never>(nil)
init(itemProvider: NSItemProvider) {
self.itemProvider = itemProvider
// bind attachment from item provider
Just(itemProvider)
.receive(on: DispatchQueue.main)
.flatMap { result -> AnyPublisher<Mastodon.Query.MediaAttachment?, Error> in
@ -51,18 +85,49 @@ final class StatusAttachmentViewModel: ObservableObject, Identifiable {
switch completion {
case .failure(let error):
self.error = error
// self.uploadStateMachine.enter(UploadState.Fail.self)
self.uploadStateMachine.enter(UploadState.Fail.self)
case .finished:
break
}
} receiveValue: { [weak self] file in
guard let self = self else { return }
self.file.value = file
// self.uploadStateMachine.enter(UploadState.Initial.self)
self.uploadStateMachine.enter(UploadState.Initial.self)
}
.store(in: &disposeBag)
// bind progress view tint color
$thumbnailImage
.receive(on: DispatchQueue.main)
.map { image -> UIColor in
guard let image = image else { return .systemFill }
switch image.domainLumaCoefficientsStyle {
case .light:
return UIColor.black.withAlphaComponent(0.8)
default:
return UIColor.white.withAlphaComponent(0.8)
}
}
.assign(to: &$progressViewTintColor)
// bind description placeholder and error prompt image
file
.receive(on: DispatchQueue.main)
.sink { [weak self] file in
guard let self = self else { return }
guard let file = file else { return }
switch file {
case .jpeg, .png, .gif:
self.descriptionPlaceholder = L10n.Scene.Compose.Attachment.descriptionPhoto
self.errorPromptImage = StatusAttachmentViewModel.photoFillSplitImage
case .other:
self.descriptionPlaceholder = L10n.Scene.Compose.Attachment.descriptionVideo
self.errorPromptImage = StatusAttachmentViewModel.videoSplashImage
}
}
.store(in: &disposeBag)
// bind thumbnail image
file
.receive(on: DispatchQueue.main)
.map { file -> UIImage? in
@ -92,6 +157,56 @@ final class StatusAttachmentViewModel: ObservableObject, Identifiable {
}
}
.assign(to: &$thumbnailImage)
// bind state and error
Publishers.CombineLatest(
uploadStateMachineSubject,
$error
)
.sink { [weak self] state, error in
guard let self = self else { return }
// trigger delegate
self.delegate?.statusAttachmentViewModel(self, uploadStateDidChange: state)
// set error prompt
if let error = error {
self.isUploading = false
self.errorPrompt = error.localizedDescription
} else {
guard let state = state else { return }
switch state {
case is UploadState.Finish:
self.isUploading = false
case is UploadState.Fail:
self.isUploading = false
// FIXME: not display
self.errorPrompt = {
guard let file = self.file.value else {
return L10n.Scene.Compose.Attachment.attachmentBroken(L10n.Scene.Compose.Attachment.photo)
}
switch file {
case .jpeg, .png, .gif:
return L10n.Scene.Compose.Attachment.attachmentBroken(L10n.Scene.Compose.Attachment.photo)
case .other:
return L10n.Scene.Compose.Attachment.attachmentBroken(L10n.Scene.Compose.Attachment.video)
}
}()
default:
break
}
}
}
.store(in: &disposeBag)
// trigger delegate when authentication get new value
authentication
.receive(on: DispatchQueue.main)
.sink { [weak self] authentication in
guard let self = self else { return }
guard authentication != nil else { return }
self.delegate?.statusAttachmentViewModel(self, uploadStateDidChange: self.uploadStateMachineSubject.value)
}
.store(in: &disposeBag)
}
}

View File

@ -21,11 +21,12 @@ struct StatusAuthorView: View {
HStack(spacing: 5) {
AnimatedImage(imageURL: avatarImageURL)
.frame(width: 42, height: 42)
.background(Color(UIColor.systemFill))
.cornerRadius(4)
VStack(alignment: .leading) {
Text(name)
.font(.headline)
Text("@" + username)
Text(username)
.font(.subheadline)
.foregroundColor(.secondary)
}

View File

@ -16,19 +16,22 @@ public struct StatusEditorView: UIViewRepresentable {
let width: CGFloat
let attributedString: NSAttributedString
let keyboardType: UIKeyboardType
@Binding var viewDidAppear: Bool
public init(
string: Binding<String>,
placeholder: String,
width: CGFloat,
attributedString: NSAttributedString,
keyboardType: UIKeyboardType
keyboardType: UIKeyboardType,
viewDidAppear: Binding<Bool>
) {
self._string = string
self.placeholder = placeholder
self.width = width
self.attributedString = attributedString
self.keyboardType = keyboardType
self._viewDidAppear = viewDidAppear
}
public func makeUIView(context: Context) -> UITextView {
@ -45,6 +48,7 @@ public struct StatusEditorView: UIViewRepresentable {
let widthLayoutConstraint = textView.widthAnchor.constraint(equalToConstant: 100)
widthLayoutConstraint.priority = .required - 1
context.coordinator.widthLayoutConstraint = widthLayoutConstraint
return textView
}
@ -55,6 +59,12 @@ public struct StatusEditorView: UIViewRepresentable {
// update layout
context.coordinator.updateLayout(width: width)
// set becomeFirstResponder
if viewDidAppear {
viewDidAppear = false
textView.becomeFirstResponder()
}
}
public func makeCoordinator() -> Coordinator {

View File

@ -0,0 +1,32 @@
//
// APIService.swift
// ShareActionExtension
//
// Created by MainasuK Cirno on 2021-7-20.
//
import os.log
import Foundation
import Combine
import CoreData
import CoreDataStack
import MastodonSDK
// Replica APIService for share extension
final class APIService {
var disposeBag = Set<AnyCancellable>()
static let shared = APIService()
// internal
let session: URLSession
// output
let error = PassthroughSubject<APIError, Never>()
private init() {
self.session = URLSession(configuration: .default)
}
}