Merge pull request #142 from tootsuite/feature/post-video
Add publish video support
This commit is contained in:
commit
356d41672e
|
@ -8,7 +8,7 @@
|
|||
import Foundation
|
||||
import CoreData
|
||||
|
||||
public protocol Managed: AnyObject, NSFetchRequestResult {
|
||||
public protocol Managed: NSFetchRequestResult {
|
||||
static var entityName: String { get }
|
||||
static var defaultSortDescriptors: [NSSortDescriptor] { get }
|
||||
}
|
||||
|
|
|
@ -21,7 +21,11 @@
|
|||
},
|
||||
"publish_post_failure": {
|
||||
"title": "Publish Failure",
|
||||
"message": "Failed to publish the post.\nPlease check your internet connection."
|
||||
"message": "Failed to publish the post.\nPlease check your internet connection.",
|
||||
"attchments_message": {
|
||||
"video_attach_with_photo": "Cannot attach a video to a status that already contains images.",
|
||||
"more_than_one_video": "Cannot attach more than one video."
|
||||
}
|
||||
},
|
||||
"sign_out": {
|
||||
"title": "Sign out",
|
||||
|
@ -39,6 +43,10 @@
|
|||
"delete_post": {
|
||||
"title": "Are you sure you want to delete this post?",
|
||||
"delete": "Delete"
|
||||
},
|
||||
"clean_cache": {
|
||||
"title": "Clean Cache",
|
||||
"message": "Successfully clean %s cache."
|
||||
}
|
||||
},
|
||||
"controls": {
|
||||
|
@ -165,6 +173,9 @@
|
|||
"edit_info": "Edit info"
|
||||
},
|
||||
"timeline": {
|
||||
"timestamp": {
|
||||
"now": "Now"
|
||||
},
|
||||
"loader": {
|
||||
"load_missing_posts": "Load missing posts",
|
||||
"loading_missing_posts": "Loading missing posts...",
|
||||
|
|
|
@ -178,9 +178,8 @@
|
|||
5DFC35DF262068D20045711D /* SearchViewController+Follow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DFC35DE262068D20045711D /* SearchViewController+Follow.swift */; };
|
||||
5E0DEC05797A7E6933788DDB /* Pods_MastodonTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 452147B2903DF38070FE56A2 /* Pods_MastodonTests.framework */; };
|
||||
5E44BF88AD33646E64727BCF /* Pods_MastodonTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CD92E0F10BDE4FE7C4B999F2 /* Pods_MastodonTests.framework */; };
|
||||
7D603B3DC600BB75956FAD7D /* Pods_Mastodon_NotificationService.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 79D7EB88A6A8B34DFDFC96FC /* Pods_Mastodon_NotificationService.framework */; };
|
||||
87FFDA5D898A5C42ADCB35E7 /* Pods_Mastodon.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A4ABE34829701A4496C5BB64 /* Pods_Mastodon.framework */; };
|
||||
D86919F5080C3F228CCD17D1 /* Pods_Mastodon_AppShared.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 46531DECCAB422F507B2274D /* Pods_Mastodon_AppShared.framework */; };
|
||||
B914FC6B0B8AF18573C0B291 /* Pods_NotificationService.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 374AA339A20E0FAC75BCDA6D /* Pods_NotificationService.framework */; };
|
||||
DB00CA972632DDB600A54956 /* CommonOSLog in Frameworks */ = {isa = PBXBuildFile; productRef = DB00CA962632DDB600A54956 /* CommonOSLog */; };
|
||||
DB0140A125C40C0600F9F3CF /* MastodonPinBasedAuthenticationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0140A025C40C0600F9F3CF /* MastodonPinBasedAuthenticationViewController.swift */; };
|
||||
DB0140A825C40C1500F9F3CF /* MastodonPinBasedAuthenticationViewModelNavigationDelegateShim.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0140A725C40C1500F9F3CF /* MastodonPinBasedAuthenticationViewModelNavigationDelegateShim.swift */; };
|
||||
|
@ -375,6 +374,7 @@
|
|||
DB938F1F2624382F00E5B6C1 /* ThreadViewModel+Diffable.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB938F1E2624382F00E5B6C1 /* ThreadViewModel+Diffable.swift */; };
|
||||
DB938F25262438D600E5B6C1 /* ThreadViewController+Provider.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB938F24262438D600E5B6C1 /* ThreadViewController+Provider.swift */; };
|
||||
DB938F3326243D6200E5B6C1 /* TimelineTopLoaderTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB938F3226243D6200E5B6C1 /* TimelineTopLoaderTableViewCell.swift */; };
|
||||
DB97131F2666078B00BD1E90 /* Date.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB97131E2666078B00BD1E90 /* Date.swift */; };
|
||||
DB98336B25C9420100AD9700 /* APIService+App.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB98336A25C9420100AD9700 /* APIService+App.swift */; };
|
||||
DB98337125C9443200AD9700 /* APIService+Authentication.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB98337025C9443200AD9700 /* APIService+Authentication.swift */; };
|
||||
DB98337F25C9452D00AD9700 /* APIService+APIError.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB98337E25C9452D00AD9700 /* APIService+APIError.swift */; };
|
||||
|
@ -468,6 +468,7 @@
|
|||
DBF96326262EC0A6001D8D25 /* AuthenticationServices.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DBF96325262EC0A6001D8D25 /* AuthenticationServices.framework */; };
|
||||
DBF9814A265E24F500E4BA07 /* ProfileFieldCollectionViewHeaderFooterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBF98149265E24F500E4BA07 /* ProfileFieldCollectionViewHeaderFooterView.swift */; };
|
||||
DBF9814C265E339500E4BA07 /* ProfileFieldAddEntryCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBF9814B265E339500E4BA07 /* ProfileFieldAddEntryCollectionViewCell.swift */; };
|
||||
EE93E8E8F9E0C39EAAEBD92F /* Pods_AppShared.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F4A2A2D7000E477CA459ADA9 /* Pods_AppShared.framework */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
|
@ -704,10 +705,10 @@
|
|||
2DFAD5262616F9D300F9EE7C /* SearchViewController+Searching.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SearchViewController+Searching.swift"; sourceTree = "<group>"; };
|
||||
2DFAD5362617010500F9EE7C /* SearchingTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchingTableViewCell.swift; sourceTree = "<group>"; };
|
||||
2E1F6A67FDF9771D3E064FDC /* Pods-Mastodon.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Mastodon.debug.xcconfig"; path = "Target Support Files/Pods-Mastodon/Pods-Mastodon.debug.xcconfig"; sourceTree = "<group>"; };
|
||||
374AA339A20E0FAC75BCDA6D /* Pods_NotificationService.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_NotificationService.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
3C030226D3C73DCC23D67452 /* Pods_Mastodon_MastodonUITests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Mastodon_MastodonUITests.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
452147B2903DF38070FE56A2 /* Pods_MastodonTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_MastodonTests.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
459EA4F43058CAB47719E963 /* Pods-Mastodon-MastodonUITests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Mastodon-MastodonUITests.debug.xcconfig"; path = "Target Support Files/Pods-Mastodon-MastodonUITests/Pods-Mastodon-MastodonUITests.debug.xcconfig"; sourceTree = "<group>"; };
|
||||
46531DECCAB422F507B2274D /* Pods_Mastodon_AppShared.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Mastodon_AppShared.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
5B24BBD7262DB14800A9381B /* ReportViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ReportViewModel.swift; sourceTree = "<group>"; };
|
||||
5B24BBD8262DB14800A9381B /* ReportViewModel+Diffable.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "ReportViewModel+Diffable.swift"; sourceTree = "<group>"; };
|
||||
5B24BBE1262DB19100A9381B /* APIService+Report.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "APIService+Report.swift"; sourceTree = "<group>"; };
|
||||
|
@ -741,9 +742,11 @@
|
|||
5DF1058425F88AE500D6C0D4 /* NeedsDependency+AVPlayerViewControllerDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NeedsDependency+AVPlayerViewControllerDelegate.swift"; sourceTree = "<group>"; };
|
||||
5DFC35DE262068D20045711D /* SearchViewController+Follow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SearchViewController+Follow.swift"; sourceTree = "<group>"; };
|
||||
75E3471C898DDD9631729B6E /* Pods-Mastodon.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Mastodon.release.xcconfig"; path = "Target Support Files/Pods-Mastodon/Pods-Mastodon.release.xcconfig"; sourceTree = "<group>"; };
|
||||
79D7EB88A6A8B34DFDFC96FC /* Pods_Mastodon_NotificationService.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Mastodon_NotificationService.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
8ED8C4B1F1BA2DCFF2926BB1 /* Pods-Mastodon-NotificationService.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Mastodon-NotificationService.debug.xcconfig"; path = "Target Support Files/Pods-Mastodon-NotificationService/Pods-Mastodon-NotificationService.debug.xcconfig"; sourceTree = "<group>"; };
|
||||
9553C689FFA9EBC880CAB78D /* Pods-NotificationService.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-NotificationService.debug.xcconfig"; path = "Target Support Files/Pods-NotificationService/Pods-NotificationService.debug.xcconfig"; sourceTree = "<group>"; };
|
||||
9776D7C4B79101CF70181127 /* Pods-NotificationService.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-NotificationService.release.xcconfig"; path = "Target Support Files/Pods-NotificationService/Pods-NotificationService.release.xcconfig"; sourceTree = "<group>"; };
|
||||
9780A4C98FFC65B32B50D1C0 /* Pods-MastodonTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-MastodonTests.release.xcconfig"; path = "Target Support Files/Pods-MastodonTests/Pods-MastodonTests.release.xcconfig"; sourceTree = "<group>"; };
|
||||
9A0982D8F349244EB558CDFD /* Pods-AppShared.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-AppShared.debug.xcconfig"; path = "Target Support Files/Pods-AppShared/Pods-AppShared.debug.xcconfig"; sourceTree = "<group>"; };
|
||||
A4ABE34829701A4496C5BB64 /* Pods_Mastodon.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Mastodon.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
B31D44635FCF6452F7E1B865 /* Pods-Mastodon-AppShared.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Mastodon-AppShared.release.xcconfig"; path = "Target Support Files/Pods-Mastodon-AppShared/Pods-Mastodon-AppShared.release.xcconfig"; sourceTree = "<group>"; };
|
||||
B44342AC2B6585F8295F1DDF /* Pods-Mastodon-NotificationService.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Mastodon-NotificationService.release.xcconfig"; path = "Target Support Files/Pods-Mastodon-NotificationService/Pods-Mastodon-NotificationService.release.xcconfig"; sourceTree = "<group>"; };
|
||||
|
@ -944,6 +947,7 @@
|
|||
DB938F1E2624382F00E5B6C1 /* ThreadViewModel+Diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ThreadViewModel+Diffable.swift"; sourceTree = "<group>"; };
|
||||
DB938F24262438D600E5B6C1 /* ThreadViewController+Provider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ThreadViewController+Provider.swift"; sourceTree = "<group>"; };
|
||||
DB938F3226243D6200E5B6C1 /* TimelineTopLoaderTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineTopLoaderTableViewCell.swift; sourceTree = "<group>"; };
|
||||
DB97131E2666078B00BD1E90 /* Date.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Date.swift; sourceTree = "<group>"; };
|
||||
DB98336A25C9420100AD9700 /* APIService+App.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+App.swift"; sourceTree = "<group>"; };
|
||||
DB98337025C9443200AD9700 /* APIService+Authentication.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Authentication.swift"; sourceTree = "<group>"; };
|
||||
DB98337E25C9452D00AD9700 /* APIService+APIError.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "APIService+APIError.swift"; sourceTree = "<group>"; };
|
||||
|
@ -1036,6 +1040,8 @@
|
|||
DBF98149265E24F500E4BA07 /* ProfileFieldCollectionViewHeaderFooterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileFieldCollectionViewHeaderFooterView.swift; sourceTree = "<group>"; };
|
||||
DBF9814B265E339500E4BA07 /* ProfileFieldAddEntryCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileFieldAddEntryCollectionViewCell.swift; 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>"; };
|
||||
ECA373ABA86BE3C2D7ED878E /* Pods-AppShared.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-AppShared.release.xcconfig"; path = "Target Support Files/Pods-AppShared/Pods-AppShared.release.xcconfig"; sourceTree = "<group>"; };
|
||||
F4A2A2D7000E477CA459ADA9 /* Pods_AppShared.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_AppShared.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
|
@ -1084,7 +1090,7 @@
|
|||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
DB6805102637D0F800430867 /* KeychainAccess in Frameworks */,
|
||||
D86919F5080C3F228CCD17D1 /* Pods_Mastodon_AppShared.framework in Frameworks */,
|
||||
EE93E8E8F9E0C39EAAEBD92F /* Pods_AppShared.framework in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
|
@ -1112,7 +1118,7 @@
|
|||
DB6D9F42263527CE008423CD /* AlamofireImage in Frameworks */,
|
||||
DBF8AE862632992800C9C23C /* Base85 in Frameworks */,
|
||||
DB6804A52637CDCC00430867 /* AppShared.framework in Frameworks */,
|
||||
7D603B3DC600BB75956FAD7D /* Pods_Mastodon_NotificationService.framework in Frameworks */,
|
||||
B914FC6B0B8AF18573C0B291 /* Pods_NotificationService.framework in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
|
@ -1199,6 +1205,10 @@
|
|||
B44342AC2B6585F8295F1DDF /* Pods-Mastodon-NotificationService.release.xcconfig */,
|
||||
D7D7CF93E262178800077512 /* Pods-Mastodon-AppShared.debug.xcconfig */,
|
||||
B31D44635FCF6452F7E1B865 /* Pods-Mastodon-AppShared.release.xcconfig */,
|
||||
9A0982D8F349244EB558CDFD /* Pods-AppShared.debug.xcconfig */,
|
||||
ECA373ABA86BE3C2D7ED878E /* Pods-AppShared.release.xcconfig */,
|
||||
9553C689FFA9EBC880CAB78D /* Pods-NotificationService.debug.xcconfig */,
|
||||
9776D7C4B79101CF70181127 /* Pods-NotificationService.release.xcconfig */,
|
||||
);
|
||||
path = Pods;
|
||||
sourceTree = "<group>";
|
||||
|
@ -1544,8 +1554,8 @@
|
|||
A4ABE34829701A4496C5BB64 /* Pods_Mastodon.framework */,
|
||||
3C030226D3C73DCC23D67452 /* Pods_Mastodon_MastodonUITests.framework */,
|
||||
452147B2903DF38070FE56A2 /* Pods_MastodonTests.framework */,
|
||||
79D7EB88A6A8B34DFDFC96FC /* Pods_Mastodon_NotificationService.framework */,
|
||||
46531DECCAB422F507B2274D /* Pods_Mastodon_AppShared.framework */,
|
||||
F4A2A2D7000E477CA459ADA9 /* Pods_AppShared.framework */,
|
||||
374AA339A20E0FAC75BCDA6D /* Pods_NotificationService.framework */,
|
||||
);
|
||||
name = Frameworks;
|
||||
sourceTree = "<group>";
|
||||
|
@ -2159,6 +2169,7 @@
|
|||
DBCC3B2F261440A50045B23D /* UITabBarController.swift */,
|
||||
DBCC3B35261440BA0045B23D /* UINavigationController.swift */,
|
||||
DB6D1B23263684C600ACB481 /* UserDefaults.swift */,
|
||||
DB97131E2666078B00BD1E90 /* Date.swift */,
|
||||
);
|
||||
path = Extension;
|
||||
sourceTree = "<group>";
|
||||
|
@ -2780,7 +2791,7 @@
|
|||
outputFileListPaths = (
|
||||
);
|
||||
outputPaths = (
|
||||
"$(DERIVED_FILE_DIR)/Pods-Mastodon-NotificationService-checkManifestLockResult.txt",
|
||||
"$(DERIVED_FILE_DIR)/Pods-NotificationService-checkManifestLockResult.txt",
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
|
@ -2863,7 +2874,7 @@
|
|||
outputFileListPaths = (
|
||||
);
|
||||
outputPaths = (
|
||||
"$(DERIVED_FILE_DIR)/Pods-Mastodon-AppShared-checkManifestLockResult.txt",
|
||||
"$(DERIVED_FILE_DIR)/Pods-AppShared-checkManifestLockResult.txt",
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
|
@ -3044,6 +3055,7 @@
|
|||
DBE3CDBB261C427900430CC6 /* TimelineHeaderTableViewCell.swift in Sources */,
|
||||
0FAA101C25E10E760017CCDE /* UIFont.swift in Sources */,
|
||||
2D38F1D525CD465300561493 /* HomeTimelineViewController.swift in Sources */,
|
||||
DB97131F2666078B00BD1E90 /* Date.swift in Sources */,
|
||||
DB98338825C945ED00AD9700 /* Assets.swift in Sources */,
|
||||
DB6180E926391BDF0018D199 /* MediaHostToMediaPreviewViewControllerAnimatedTransitioning.swift in Sources */,
|
||||
DB6B351E2601FAEE00DC1E11 /* ComposeStatusAttachmentCollectionViewCell.swift in Sources */,
|
||||
|
@ -3746,7 +3758,7 @@
|
|||
};
|
||||
DB6804892637CD4C00430867 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = D7D7CF93E262178800077512 /* Pods-Mastodon-AppShared.debug.xcconfig */;
|
||||
baseConfigurationReference = 9A0982D8F349244EB558CDFD /* Pods-AppShared.debug.xcconfig */;
|
||||
buildSettings = {
|
||||
APPLICATION_EXTENSION_API_ONLY = YES;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
|
@ -3777,7 +3789,7 @@
|
|||
};
|
||||
DB68048A2637CD4C00430867 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = B31D44635FCF6452F7E1B865 /* Pods-Mastodon-AppShared.release.xcconfig */;
|
||||
baseConfigurationReference = ECA373ABA86BE3C2D7ED878E /* Pods-AppShared.release.xcconfig */;
|
||||
buildSettings = {
|
||||
APPLICATION_EXTENSION_API_ONLY = YES;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
|
@ -3904,7 +3916,7 @@
|
|||
};
|
||||
DBF8AE1C263293E400C9C23C /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = 8ED8C4B1F1BA2DCFF2926BB1 /* Pods-Mastodon-NotificationService.debug.xcconfig */;
|
||||
baseConfigurationReference = 9553C689FFA9EBC880CAB78D /* Pods-NotificationService.debug.xcconfig */;
|
||||
buildSettings = {
|
||||
CODE_SIGN_ENTITLEMENTS = NotificationService/NotificationService.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
|
@ -3927,7 +3939,7 @@
|
|||
};
|
||||
DBF8AE1D263293E400C9C23C /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = B44342AC2B6585F8295F1DDF /* Pods-Mastodon-NotificationService.release.xcconfig */;
|
||||
baseConfigurationReference = 9776D7C4B79101CF70181127 /* Pods-NotificationService.release.xcconfig */;
|
||||
buildSettings = {
|
||||
CODE_SIGN_ENTITLEMENTS = NotificationService/NotificationService.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
|
|
|
@ -12,7 +12,7 @@
|
|||
<key>CoreDataStack.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
<key>orderHint</key>
|
||||
<integer>14</integer>
|
||||
<integer>15</integer>
|
||||
</dict>
|
||||
<key>Mastodon - RTL.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
|
@ -32,7 +32,7 @@
|
|||
<key>NotificationService.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
<key>orderHint</key>
|
||||
<integer>15</integer>
|
||||
<integer>16</integer>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>SuppressBuildableAutocreation</key>
|
||||
|
|
|
@ -261,10 +261,6 @@ private extension SceneCoordinator {
|
|||
let _viewController = FavoriteViewController()
|
||||
_viewController.viewModel = viewModel
|
||||
viewController = _viewController
|
||||
case .settings(let viewModel):
|
||||
let _viewController = SettingsViewController()
|
||||
_viewController.viewModel = viewModel
|
||||
viewController = _viewController
|
||||
case .suggestionAccount(let viewModel):
|
||||
let _viewController = SuggestionAccountViewController()
|
||||
_viewController.viewModel = viewModel
|
||||
|
|
|
@ -76,7 +76,7 @@ extension ComposeStatusSection {
|
|||
//status.emoji
|
||||
cell.statusView.activeTextLabel.configure(content: status.content, emojiDict: [:])
|
||||
// set date
|
||||
cell.statusView.dateLabel.text = status.createdAt.shortTimeAgoSinceNow
|
||||
cell.statusView.dateLabel.text = status.createdAt.slowedTimeAgoSinceNow
|
||||
|
||||
cell.framePublisher.assign(to: \.value, on: repliedToCellFrameSubscriber).store(in: &cell.disposeBag)
|
||||
}
|
||||
|
@ -101,21 +101,24 @@ extension ComposeStatusSection {
|
|||
cell.composeContent
|
||||
.removeDuplicates()
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { text in
|
||||
.sink { [weak collectionView] text in
|
||||
guard let collectionView = collectionView else { return }
|
||||
// self size input cell
|
||||
// needs restore content offset to resolve issue #83
|
||||
let oldContentOffset = collectionView.contentOffset
|
||||
collectionView.collectionViewLayout.invalidateLayout()
|
||||
collectionView.layoutIfNeeded()
|
||||
collectionView.contentOffset = oldContentOffset
|
||||
|
||||
|
||||
// bind input data
|
||||
attribute.composeContent.value = text
|
||||
}
|
||||
.store(in: &cell.disposeBag)
|
||||
attribute.isContentWarningComposing
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { isContentWarningComposing in
|
||||
.sink { [weak cell, weak collectionView] isContentWarningComposing in
|
||||
guard let cell = cell else { return }
|
||||
guard let collectionView = collectionView else { return }
|
||||
// self size input cell
|
||||
collectionView.collectionViewLayout.invalidateLayout()
|
||||
cell.statusContentWarningEditorView.containerView.isHidden = !isContentWarningComposing
|
||||
|
@ -130,7 +133,8 @@ extension ComposeStatusSection {
|
|||
cell.contentWarningContent
|
||||
.removeDuplicates()
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { text in
|
||||
.sink { [weak collectionView] text in
|
||||
guard let collectionView = collectionView else { return }
|
||||
// self size input cell
|
||||
collectionView.collectionViewLayout.invalidateLayout()
|
||||
// bind input data
|
||||
|
@ -145,12 +149,12 @@ extension ComposeStatusSection {
|
|||
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: ComposeStatusAttachmentCollectionViewCell.self), for: indexPath) as! ComposeStatusAttachmentCollectionViewCell
|
||||
cell.attachmentContainerView.descriptionTextView.text = attachmentService.description.value
|
||||
cell.delegate = composeStatusAttachmentTableViewCellDelegate
|
||||
attachmentService.imageData
|
||||
attachmentService.thumbnailImage
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { imageData in
|
||||
.sink { [weak cell] thumbnailImage in
|
||||
guard let cell = cell else { return }
|
||||
let size = cell.attachmentContainerView.previewImageView.frame.size != .zero ? cell.attachmentContainerView.previewImageView.frame.size : CGSize(width: 1, height: 1)
|
||||
guard let imageData = imageData,
|
||||
let image = UIImage(data: imageData) else {
|
||||
guard let image = thumbnailImage else {
|
||||
let placeholder = UIImage.placeholder(
|
||||
size: size,
|
||||
color: Asset.Colors.Background.systemGroupedBackground.color
|
||||
|
@ -171,17 +175,32 @@ extension ComposeStatusSection {
|
|||
attachmentService.error.eraseToAnyPublisher()
|
||||
)
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { uploadState, error in
|
||||
.sink { [weak cell, weak attachmentService] uploadState, error in
|
||||
guard let cell = cell else { return }
|
||||
guard let attachmentService = attachmentService else { return }
|
||||
cell.attachmentContainerView.emptyStateView.isHidden = error == nil
|
||||
cell.attachmentContainerView.descriptionBackgroundView.isHidden = error != nil
|
||||
if let _ = error {
|
||||
if let error = error {
|
||||
cell.attachmentContainerView.activityIndicatorView.stopAnimating()
|
||||
cell.attachmentContainerView.emptyStateView.label.text = error.localizedDescription
|
||||
} else {
|
||||
guard let uploadState = uploadState else { return }
|
||||
switch uploadState {
|
||||
case is MastodonAttachmentService.UploadState.Finish,
|
||||
is MastodonAttachmentService.UploadState.Fail:
|
||||
cell.attachmentContainerView.activityIndicatorView.stopAnimating()
|
||||
cell.attachmentContainerView.emptyStateView.label.text = {
|
||||
if let file = attachmentService.file.value {
|
||||
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)
|
||||
}
|
||||
} else {
|
||||
return L10n.Scene.Compose.Attachment.attachmentBroken(L10n.Scene.Compose.Attachment.photo)
|
||||
}
|
||||
}()
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
@ -220,7 +239,8 @@ extension ComposeStatusSection {
|
|||
cell.durationButton.setTitle(L10n.Scene.Compose.Poll.durationTime(attribute.expiresOption.value.title), for: .normal)
|
||||
attribute.expiresOption
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { expiresOption in
|
||||
.sink { [weak cell] expiresOption in
|
||||
guard let cell = cell else { return }
|
||||
cell.durationButton.setTitle(L10n.Scene.Compose.Poll.durationTime(expiresOption.title), for: .normal)
|
||||
}
|
||||
.store(in: &cell.disposeBag)
|
||||
|
|
|
@ -36,7 +36,7 @@ extension NotificationSection {
|
|||
assertionFailure()
|
||||
return nil
|
||||
}
|
||||
let timeText = notification.createAt.shortTimeAgoSinceNow
|
||||
let timeText = notification.createAt.slowedTimeAgoSinceNow
|
||||
|
||||
let actionText = type.actionText
|
||||
let actionImageName = type.actionImageName
|
||||
|
@ -59,7 +59,7 @@ extension NotificationSection {
|
|||
)
|
||||
timestampUpdatePublisher
|
||||
.sink { _ in
|
||||
let timeText = notification.createAt.shortTimeAgoSinceNow
|
||||
let timeText = notification.createAt.slowedTimeAgoSinceNow
|
||||
cell.actionLabel.text = actionText + " · " + timeText
|
||||
}
|
||||
.store(in: &cell.disposeBag)
|
||||
|
@ -87,7 +87,7 @@ extension NotificationSection {
|
|||
cell.delegate = delegate
|
||||
timestampUpdatePublisher
|
||||
.sink { _ in
|
||||
let timeText = notification.createAt.shortTimeAgoSinceNow
|
||||
let timeText = notification.createAt.slowedTimeAgoSinceNow
|
||||
cell.actionLabel.text = actionText + " · " + timeText
|
||||
}
|
||||
.store(in: &cell.disposeBag)
|
||||
|
|
|
@ -28,10 +28,6 @@ extension ProfileFieldSection {
|
|||
case .field(let field, let attribute):
|
||||
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: ProfileFieldCollectionViewCell.self), for: indexPath) as! ProfileFieldCollectionViewCell
|
||||
|
||||
let margin = max(0, collectionView.frame.width - collectionView.readableContentGuide.layoutFrame.width)
|
||||
cell.containerStackView.layoutMargins = UIEdgeInsets(top: 0, left: margin, bottom: 0, right: margin)
|
||||
cell.separatorLineToMarginLeadingLayoutConstraint.constant = margin
|
||||
|
||||
// set key
|
||||
cell.fieldView.titleActiveLabel.configure(field: field.name.value, emojiDict: attribute.emojiDict.value)
|
||||
cell.fieldView.titleTextField.text = field.name.value
|
||||
|
@ -99,10 +95,6 @@ extension ProfileFieldSection {
|
|||
case .addEntry(let attribute):
|
||||
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: ProfileFieldAddEntryCollectionViewCell.self), for: indexPath) as! ProfileFieldAddEntryCollectionViewCell
|
||||
|
||||
let margin = max(0, collectionView.frame.width - collectionView.readableContentGuide.layoutFrame.width)
|
||||
cell.containerStackView.layoutMargins = UIEdgeInsets(top: 0, left: margin, bottom: 0, right: margin)
|
||||
cell.separatorLineToMarginLeadingLayoutConstraint.constant = margin
|
||||
|
||||
cell.bottomSeparatorLine.isHidden = attribute.isLast
|
||||
cell.delegate = profileFieldAddEntryCollectionViewCellDelegate
|
||||
|
||||
|
|
|
@ -274,7 +274,8 @@ extension StatusSection {
|
|||
} else {
|
||||
meta.blurhashImagePublisher()
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak cell] image in
|
||||
.sink { [weak blurhashImageCache] image in
|
||||
guard let blurhashImageCache = blurhashImageCache else { return }
|
||||
blurhashOverlayImageView.image = image
|
||||
image?.pngData().flatMap {
|
||||
blurhashImageCache.setObject($0 as NSData, forKey: blurhashImageDataKey)
|
||||
|
@ -467,12 +468,12 @@ extension StatusSection {
|
|||
|
||||
// set date
|
||||
let createdAt = (status.reblog ?? status).createdAt
|
||||
cell.statusView.dateLabel.text = createdAt.shortTimeAgoSinceNow
|
||||
cell.statusView.dateLabel.text = createdAt.slowedTimeAgoSinceNow
|
||||
timestampUpdatePublisher
|
||||
.sink { [weak cell] _ in
|
||||
guard let cell = cell else { return }
|
||||
cell.statusView.dateLabel.text = createdAt.shortTimeAgoSinceNow
|
||||
cell.statusView.dateLabel.accessibilityLabel = createdAt.timeAgoSinceNow
|
||||
cell.statusView.dateLabel.text = createdAt.slowedTimeAgoSinceNow
|
||||
cell.statusView.dateLabel.accessibilityLabel = createdAt.slowedTimeAgoSinceNow
|
||||
}
|
||||
.store(in: &cell.disposeBag)
|
||||
|
||||
|
|
|
@ -0,0 +1,29 @@
|
|||
//
|
||||
// Date.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by MainasuK Cirno on 2021-6-1.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import DateToolsSwift
|
||||
|
||||
extension Date {
|
||||
|
||||
var slowedTimeAgoSinceNow: String {
|
||||
return self.slowedTimeAgo(since: Date())
|
||||
|
||||
}
|
||||
|
||||
func slowedTimeAgo(since date: Date) -> String {
|
||||
let earlierDate = date < self ? date : self
|
||||
let latest = earlierDate == date ? self : date
|
||||
|
||||
if earlierDate.timeIntervalSince(latest) >= -60 {
|
||||
return L10n.Common.Controls.Timeline.Timestamp.now
|
||||
} else {
|
||||
return latest.shortTimeAgo(since: earlierDate)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -21,6 +21,14 @@ internal enum L10n {
|
|||
return L10n.tr("Localizable", "Common.Alerts.BlockDomain.Title", String(describing: p1))
|
||||
}
|
||||
}
|
||||
internal enum CleanCache {
|
||||
/// Successfully clean %@ cache.
|
||||
internal static func message(_ p1: Any) -> String {
|
||||
return L10n.tr("Localizable", "Common.Alerts.CleanCache.Message", String(describing: p1))
|
||||
}
|
||||
/// Clean Cache
|
||||
internal static let title = L10n.tr("Localizable", "Common.Alerts.CleanCache.Title")
|
||||
}
|
||||
internal enum Common {
|
||||
/// Please try again.
|
||||
internal static let pleaseTryAgain = L10n.tr("Localizable", "Common.Alerts.Common.PleaseTryAgain")
|
||||
|
@ -44,6 +52,12 @@ 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 {
|
||||
/// Cannot attach more than one video.
|
||||
internal static let moreThanOneVideo = L10n.tr("Localizable", "Common.Alerts.PublishPostFailure.AttchmentsMessage.MoreThanOneVideo")
|
||||
/// Cannot attach a video to a status that already contains images.
|
||||
internal static let videoAttachWithPhoto = L10n.tr("Localizable", "Common.Alerts.PublishPostFailure.AttchmentsMessage.VideoAttachWithPhoto")
|
||||
}
|
||||
}
|
||||
internal enum SavePhotoFailure {
|
||||
/// Please enable photo libaray access permission to save photo.
|
||||
|
@ -366,6 +380,10 @@ internal enum L10n {
|
|||
/// Show more replies
|
||||
internal static let showMoreReplies = L10n.tr("Localizable", "Common.Controls.Timeline.Loader.ShowMoreReplies")
|
||||
}
|
||||
internal enum Timestamp {
|
||||
/// Now
|
||||
internal static let now = L10n.tr("Localizable", "Common.Controls.Timeline.Timestamp.Now")
|
||||
}
|
||||
}
|
||||
}
|
||||
internal enum Countable {
|
||||
|
|
|
@ -1,11 +1,15 @@
|
|||
"Common.Alerts.BlockDomain.BlockEntireDomain" = "Block entire domain";
|
||||
"Common.Alerts.BlockDomain.Title" = "Are you really, really sure you want to block the entire %@? In most cases a few targeted blocks or mutes are sufficient and preferable. You will not see content from that domain in any public timelines or your notifications. Your followers from that domain will be removed.";
|
||||
"Common.Alerts.CleanCache.Message" = "Successfully clean %@ cache.";
|
||||
"Common.Alerts.CleanCache.Title" = "Clean Cache";
|
||||
"Common.Alerts.Common.PleaseTryAgain" = "Please try again.";
|
||||
"Common.Alerts.Common.PleaseTryAgainLater" = "Please try again later.";
|
||||
"Common.Alerts.DeletePost.Delete" = "Delete";
|
||||
"Common.Alerts.DeletePost.Title" = "Are you sure you want to delete this post?";
|
||||
"Common.Alerts.DiscardPostContent.Message" = "Confirm discard composed post content.";
|
||||
"Common.Alerts.DiscardPostContent.Title" = "Discard Publish";
|
||||
"Common.Alerts.PublishPostFailure.AttchmentsMessage.MoreThanOneVideo" = "Cannot attach more than one video.";
|
||||
"Common.Alerts.PublishPostFailure.AttchmentsMessage.VideoAttachWithPhoto" = "Cannot attach a video to a status that already contains images.";
|
||||
"Common.Alerts.PublishPostFailure.Message" = "Failed to publish the post.
|
||||
Please check your internet connection.";
|
||||
"Common.Alerts.PublishPostFailure.Title" = "Publish Failure";
|
||||
|
@ -128,6 +132,7 @@ Your account looks like this to them.";
|
|||
"Common.Controls.Timeline.Loader.LoadMissingPosts" = "Load missing posts";
|
||||
"Common.Controls.Timeline.Loader.LoadingMissingPosts" = "Loading missing posts...";
|
||||
"Common.Controls.Timeline.Loader.ShowMoreReplies" = "Show more replies";
|
||||
"Common.Controls.Timeline.Timestamp.Now" = "Now";
|
||||
"Common.Countable.Photo.Multiple" = "photos";
|
||||
"Common.Countable.Photo.Single" = "photo";
|
||||
"Scene.Compose.Accessibility.AppendAttachment" = "Append attachment";
|
||||
|
|
|
@ -1,11 +1,15 @@
|
|||
"Common.Alerts.BlockDomain.BlockEntireDomain" = "Block entire domain";
|
||||
"Common.Alerts.BlockDomain.Title" = "Are you really, really sure you want to block the entire %@? In most cases a few targeted blocks or mutes are sufficient and preferable. You will not see content from that domain in any public timelines or your notifications. Your followers from that domain will be removed.";
|
||||
"Common.Alerts.CleanCache.Message" = "Successfully clean %@ cache.";
|
||||
"Common.Alerts.CleanCache.Title" = "Clean Cache";
|
||||
"Common.Alerts.Common.PleaseTryAgain" = "Please try again.";
|
||||
"Common.Alerts.Common.PleaseTryAgainLater" = "Please try again later.";
|
||||
"Common.Alerts.DeletePost.Delete" = "Delete";
|
||||
"Common.Alerts.DeletePost.Title" = "Are you sure you want to delete this post?";
|
||||
"Common.Alerts.DiscardPostContent.Message" = "Confirm discard composed post content.";
|
||||
"Common.Alerts.DiscardPostContent.Title" = "Discard Publish";
|
||||
"Common.Alerts.PublishPostFailure.AttchmentsMessage.MoreThanOneVideo" = "Cannot attach more than one video.";
|
||||
"Common.Alerts.PublishPostFailure.AttchmentsMessage.VideoAttachWithPhoto" = "Cannot attach a video to a status that already contains images.";
|
||||
"Common.Alerts.PublishPostFailure.Message" = "Failed to publish the post.
|
||||
Please check your internet connection.";
|
||||
"Common.Alerts.PublishPostFailure.Title" = "Publish Failure";
|
||||
|
@ -128,6 +132,7 @@ Your account looks like this to them.";
|
|||
"Common.Controls.Timeline.Loader.LoadMissingPosts" = "Load missing posts";
|
||||
"Common.Controls.Timeline.Loader.LoadingMissingPosts" = "Loading missing posts...";
|
||||
"Common.Controls.Timeline.Loader.ShowMoreReplies" = "Show more replies";
|
||||
"Common.Controls.Timeline.Timestamp.Now" = "Now";
|
||||
"Common.Countable.Photo.Multiple" = "photos";
|
||||
"Common.Countable.Photo.Single" = "photo";
|
||||
"Scene.Compose.Accessibility.AppendAttachment" = "Append attachment";
|
||||
|
|
|
@ -57,6 +57,10 @@ final class ComposeStatusAttachmentCollectionViewCell: UICollectionViewCell {
|
|||
_init()
|
||||
}
|
||||
|
||||
deinit {
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension ComposeStatusAttachmentCollectionViewCell {
|
||||
|
|
|
@ -75,12 +75,15 @@ final class ComposeViewController: UIViewController, NeedsDependency {
|
|||
var composeToolbarViewBottomLayoutConstraint: NSLayoutConstraint!
|
||||
let composeToolbarBackgroundView = UIView()
|
||||
|
||||
private(set) lazy var imagePicker: PHPickerViewController = {
|
||||
static func createPhotoLibraryPickerConfiguration(selectionLimit: Int = 4) -> PHPickerConfiguration {
|
||||
var configuration = PHPickerConfiguration()
|
||||
configuration.filter = .images
|
||||
configuration.selectionLimit = 4
|
||||
|
||||
let imagePicker = PHPickerViewController(configuration: configuration)
|
||||
configuration.filter = .any(of: [.images, .videos])
|
||||
configuration.selectionLimit = selectionLimit
|
||||
return configuration
|
||||
}
|
||||
|
||||
private(set) lazy var photoLibraryPicker: PHPickerViewController = {
|
||||
let imagePicker = PHPickerViewController(configuration: ComposeViewController.createPhotoLibraryPickerConfiguration())
|
||||
imagePicker.delegate = self
|
||||
return imagePicker
|
||||
}()
|
||||
|
@ -92,7 +95,7 @@ final class ComposeViewController: UIViewController, NeedsDependency {
|
|||
}()
|
||||
|
||||
private(set) lazy var documentPickerController: UIDocumentPickerViewController = {
|
||||
let documentPickerController = UIDocumentPickerViewController(forOpeningContentTypes: [.image])
|
||||
let documentPickerController = UIDocumentPickerViewController(forOpeningContentTypes: [.image, .movie])
|
||||
documentPickerController.delegate = self
|
||||
return documentPickerController
|
||||
}()
|
||||
|
@ -567,12 +570,9 @@ extension ComposeViewController {
|
|||
}
|
||||
|
||||
private func resetImagePicker() {
|
||||
var configuration = PHPickerConfiguration()
|
||||
configuration.filter = .images
|
||||
let selectionLimit = max(1, 4 - viewModel.attachmentServices.value.count)
|
||||
configuration.selectionLimit = selectionLimit
|
||||
|
||||
imagePicker = createImagePicker(configuration: configuration)
|
||||
let configuration = ComposeViewController.createPhotoLibraryPickerConfiguration(selectionLimit: selectionLimit)
|
||||
photoLibraryPicker = createImagePicker(configuration: configuration)
|
||||
}
|
||||
|
||||
private func createImagePicker(configuration: PHPickerConfiguration) -> PHPickerViewController {
|
||||
|
@ -610,6 +610,16 @@ extension ComposeViewController {
|
|||
|
||||
@objc private func publishBarButtonItemPressed(_ sender: UIBarButtonItem) {
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
|
||||
do {
|
||||
try viewModel.checkAttachmentPrecondition()
|
||||
} catch {
|
||||
let alertController = UIAlertController(for: error, title: nil, preferredStyle: .alert)
|
||||
let okAction = UIAlertAction(title: L10n.Common.Controls.Actions.ok, style: .default, handler: nil)
|
||||
alertController.addAction(okAction)
|
||||
coordinator.present(scene: .alertController(alertController: alertController), from: nil, transition: .alertController(animated: true, completion: nil))
|
||||
return
|
||||
}
|
||||
|
||||
guard viewModel.publishStateMachine.enter(ComposeViewModel.PublishState.Publishing.self) else {
|
||||
// TODO: handle error
|
||||
return
|
||||
|
@ -913,7 +923,7 @@ extension ComposeViewController: ComposeToolbarViewDelegate {
|
|||
func composeToolbarView(_ composeToolbarView: ComposeToolbarView, cameraButtonDidPressed sender: UIButton, mediaSelectionType type: ComposeToolbarView.MediaSelectionType) {
|
||||
switch type {
|
||||
case .photoLibrary:
|
||||
present(imagePicker, animated: true, completion: nil)
|
||||
present(photoLibraryPicker, animated: true, completion: nil)
|
||||
case .camera:
|
||||
present(imagePickerController, animated: true, completion: nil)
|
||||
case .browse:
|
||||
|
@ -1092,20 +1102,13 @@ extension ComposeViewController: UIImagePickerControllerDelegate & UINavigationC
|
|||
extension ComposeViewController: UIDocumentPickerDelegate {
|
||||
func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
|
||||
guard let url = urls.first else { return }
|
||||
|
||||
do {
|
||||
guard url.startAccessingSecurityScopedResource() else { return }
|
||||
defer { url.stopAccessingSecurityScopedResource() }
|
||||
let imageData = try Data(contentsOf: url)
|
||||
let attachmentService = MastodonAttachmentService(
|
||||
context: context,
|
||||
imageData: imageData,
|
||||
initalAuthenticationBox: viewModel.activeAuthenticationBox.value
|
||||
)
|
||||
viewModel.attachmentServices.value = viewModel.attachmentServices.value + [attachmentService]
|
||||
} catch {
|
||||
os_log("%{public}s[%{public}ld], %{public}s: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription)
|
||||
}
|
||||
|
||||
let attachmentService = MastodonAttachmentService(
|
||||
context: context,
|
||||
documentURL: url,
|
||||
initalAuthenticationBox: viewModel.activeAuthenticationBox.value
|
||||
)
|
||||
viewModel.attachmentServices.value = viewModel.attachmentServices.value + [attachmentService]
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1120,8 +1123,12 @@ extension ComposeViewController: ComposeStatusAttachmentCollectionViewCellDelega
|
|||
|
||||
var attachmentServices = viewModel.attachmentServices.value
|
||||
guard let index = attachmentServices.firstIndex(of: attachmentService) else { return }
|
||||
let removedItem = attachmentServices[index]
|
||||
attachmentServices.remove(at: index)
|
||||
viewModel.attachmentServices.value = attachmentServices
|
||||
|
||||
// cancel task
|
||||
removedItem.disposeBag.removeAll()
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -1365,7 +1372,7 @@ extension ComposeViewController {
|
|||
case .mediaBrowse:
|
||||
present(documentPickerController, animated: true, completion: nil)
|
||||
case .mediaPhotoLibrary:
|
||||
present(imagePicker, animated: true, completion: nil)
|
||||
present(photoLibraryPicker, animated: true, completion: nil)
|
||||
case .mediaCamera:
|
||||
guard UIImagePickerController.isSourceTypeAvailable(.camera) else {
|
||||
return
|
||||
|
|
|
@ -291,6 +291,8 @@ final class ComposeViewModel {
|
|||
)
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] attachmentServices, isPollComposing, pollAttributes in
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: trigger attachments upload…", ((#file as NSString).lastPathComponent), #line, #function)
|
||||
|
||||
guard let self = self else { return }
|
||||
guard let diffableDataSource = self.diffableDataSource else { return }
|
||||
var snapshot = diffableDataSource.snapshot()
|
||||
|
@ -405,6 +407,59 @@ extension ComposeViewModel {
|
|||
}
|
||||
}
|
||||
|
||||
extension ComposeViewModel {
|
||||
|
||||
enum AttachmentPrecondition: Error, LocalizedError {
|
||||
case videoAttachWithPhoto
|
||||
case moreThanOneVideo
|
||||
|
||||
var errorDescription: String? {
|
||||
return L10n.Common.Alerts.PublishPostFailure.title
|
||||
}
|
||||
|
||||
var failureReason: String? {
|
||||
switch self {
|
||||
case .videoAttachWithPhoto:
|
||||
return L10n.Common.Alerts.PublishPostFailure.AttchmentsMessage.videoAttachWithPhoto
|
||||
case .moreThanOneVideo:
|
||||
return L10n.Common.Alerts.PublishPostFailure.AttchmentsMessage.moreThanOneVideo
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// check exclusive limit:
|
||||
// - up to 1 video
|
||||
// - up to 4 photos
|
||||
func checkAttachmentPrecondition() throws {
|
||||
let attachmentServices = self.attachmentServices.value
|
||||
guard !attachmentServices.isEmpty else { return }
|
||||
var photoAttachmentServices: [MastodonAttachmentService] = []
|
||||
var videoAttachmentServices: [MastodonAttachmentService] = []
|
||||
attachmentServices.forEach { service in
|
||||
guard let file = service.file.value else {
|
||||
assertionFailure()
|
||||
return
|
||||
}
|
||||
switch file {
|
||||
case .jpeg, .png, .gif:
|
||||
photoAttachmentServices.append(service)
|
||||
case .other:
|
||||
videoAttachmentServices.append(service)
|
||||
}
|
||||
}
|
||||
|
||||
if !videoAttachmentServices.isEmpty {
|
||||
guard videoAttachmentServices.count == 1 else {
|
||||
throw AttachmentPrecondition.moreThanOneVideo
|
||||
}
|
||||
guard photoAttachmentServices.isEmpty else {
|
||||
throw AttachmentPrecondition.videoAttachWithPhoto
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - MastodonAttachmentServiceDelegate
|
||||
extension ComposeViewModel: MastodonAttachmentServiceDelegate {
|
||||
func mastodonAttachmentService(_ service: MastodonAttachmentService, uploadStateDidChange state: MastodonAttachmentService.UploadState?) {
|
||||
|
|
|
@ -29,6 +29,8 @@ extension AttachmentContainerView {
|
|||
label.textAlignment = .center
|
||||
label.text = L10n.Scene.Compose.Attachment.attachmentBroken(L10n.Scene.Compose.Attachment.photo)
|
||||
label.numberOfLines = 2
|
||||
label.adjustsFontSizeToFitWidth = true
|
||||
label.minimumScaleFactor = 0.3
|
||||
return label
|
||||
}()
|
||||
|
||||
|
|
|
@ -58,7 +58,7 @@ extension HashtagTimelineViewModel.LoadMiddleState {
|
|||
stateMachine.enter(Fail.self)
|
||||
return
|
||||
}
|
||||
let statusIDs = (viewModel.fetchedResultsController.fetchedResultsController.fetchedObjects ?? []).compactMap { status in
|
||||
_ = (viewModel.fetchedResultsController.fetchedResultsController.fetchedObjects ?? []).compactMap { status in
|
||||
status.id
|
||||
}
|
||||
|
||||
|
|
|
@ -11,6 +11,8 @@ import CoreData
|
|||
import CoreDataStack
|
||||
|
||||
#if DEBUG
|
||||
import FLEX
|
||||
|
||||
extension HomeTimelineViewController {
|
||||
var debugMenu: UIMenu {
|
||||
let menu = UIMenu(
|
||||
|
@ -19,6 +21,10 @@ extension HomeTimelineViewController {
|
|||
identifier: nil,
|
||||
options: .displayInline,
|
||||
children: [
|
||||
UIAction(title: "Show FLEX", image: nil, attributes: [], handler: { [weak self] action in
|
||||
guard let self = self else { return }
|
||||
self.showFLEXAction(action)
|
||||
}),
|
||||
moveMenu,
|
||||
dropMenu,
|
||||
UIAction(title: "Show Welcome", image: UIImage(systemName: "figure.walk"), attributes: []) { [weak self] action in
|
||||
|
@ -115,6 +121,10 @@ extension HomeTimelineViewController {
|
|||
|
||||
extension HomeTimelineViewController {
|
||||
|
||||
@objc private func showFLEXAction(_ sender: UIAction) {
|
||||
FLEXManager.shared.showExplorer()
|
||||
}
|
||||
|
||||
@objc private func moveToTopGapAction(_ sender: UIAction) {
|
||||
guard let diffableDataSource = viewModel.diffableDataSource else { return }
|
||||
let snapshotTransitioning = diffableDataSource.snapshot()
|
||||
|
|
|
@ -212,14 +212,6 @@ extension ProfileHeaderViewController {
|
|||
}
|
||||
.store(in: &disposeBag)
|
||||
|
||||
viewModel.isEditing
|
||||
.receive(on: RunLoop.main)
|
||||
.sink { [weak self] isEditing in
|
||||
guard let self = self else { return }
|
||||
// self.profileHeaderView.fieldCollectionView.
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
|
||||
profileHeaderView.editAvatarButton.menu = createAvatarContextMenu()
|
||||
profileHeaderView.editAvatarButton.showsMenuAsPrimaryAction = true
|
||||
}
|
||||
|
@ -228,14 +220,6 @@ extension ProfileHeaderViewController {
|
|||
super.viewDidAppear(animated)
|
||||
|
||||
viewModel.viewDidAppear.value = true
|
||||
|
||||
// Deprecated:
|
||||
// not needs this tweak due to force layout update in the parent
|
||||
// if !isAdjustBannerImageViewForSafeAreaInset {
|
||||
// isAdjustBannerImageViewForSafeAreaInset = true
|
||||
// profileHeaderView.bannerImageView.frame.origin.y = -containerSafeAreaInset.top
|
||||
// profileHeaderView.bannerImageView.frame.size.height += containerSafeAreaInset.top
|
||||
// }
|
||||
}
|
||||
|
||||
override func viewDidLayoutSubviews() {
|
||||
|
@ -456,9 +440,9 @@ extension ProfileHeaderViewController: PHPickerViewControllerDelegate {
|
|||
case .finished:
|
||||
break
|
||||
}
|
||||
} receiveValue: { [weak self] imageData in
|
||||
} receiveValue: { [weak self] file in
|
||||
guard let self = self else { return }
|
||||
guard let imageData = imageData else { return }
|
||||
guard let imageData = file?.data else { return }
|
||||
guard let image = UIImage(data: imageData) else { return }
|
||||
self.cropImage(image: image, pickerViewController: picker)
|
||||
}
|
||||
|
|
|
@ -45,7 +45,6 @@ final class ProfileFieldAddEntryCollectionViewCell: UICollectionViewCell {
|
|||
override func prepareForReuse() {
|
||||
super.prepareForReuse()
|
||||
|
||||
//resetStackView()
|
||||
disposeBag.removeAll()
|
||||
}
|
||||
|
||||
|
@ -71,8 +70,8 @@ extension ProfileFieldAddEntryCollectionViewCell {
|
|||
contentView.addSubview(containerStackView)
|
||||
NSLayoutConstraint.activate([
|
||||
containerStackView.topAnchor.constraint(equalTo: contentView.topAnchor),
|
||||
containerStackView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
|
||||
containerStackView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
|
||||
containerStackView.leadingAnchor.constraint(equalTo: contentView.readableContentGuide.leadingAnchor),
|
||||
containerStackView.trailingAnchor.constraint(equalTo: contentView.readableContentGuide.trailingAnchor),
|
||||
containerStackView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
|
||||
containerStackView.heightAnchor.constraint(equalToConstant: 44).priority(.defaultHigh),
|
||||
])
|
||||
|
|
|
@ -77,8 +77,8 @@ extension ProfileFieldCollectionViewCell {
|
|||
contentView.addSubview(containerStackView)
|
||||
NSLayoutConstraint.activate([
|
||||
containerStackView.topAnchor.constraint(equalTo: contentView.topAnchor),
|
||||
containerStackView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
|
||||
containerStackView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
|
||||
containerStackView.leadingAnchor.constraint(equalTo: contentView.readableContentGuide.leadingAnchor),
|
||||
containerStackView.trailingAnchor.constraint(equalTo: contentView.readableContentGuide.trailingAnchor),
|
||||
containerStackView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
|
||||
containerStackView.heightAnchor.constraint(equalToConstant: 44).priority(.defaultHigh),
|
||||
])
|
||||
|
|
|
@ -32,8 +32,9 @@ extension ProfileFieldCollectionViewHeaderFooterView {
|
|||
addSubview(separatorLine)
|
||||
NSLayoutConstraint.activate([
|
||||
separatorLine.topAnchor.constraint(equalTo: topAnchor),
|
||||
separatorLine.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||
separatorLine.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||||
// workaround SDK supplementariesFollowContentInsets not works issue
|
||||
separatorLine.leadingAnchor.constraint(equalTo: leadingAnchor, constant: -9999),
|
||||
separatorLine.trailingAnchor.constraint(equalTo: trailingAnchor, constant: 9999),
|
||||
separatorLine.bottomAnchor.constraint(equalTo: bottomAnchor),
|
||||
separatorLine.heightAnchor.constraint(equalToConstant: UIView.separatorLineHeight(of: self)).priority(.defaultHigh),
|
||||
])
|
||||
|
|
|
@ -30,7 +30,6 @@ final class ProfileFieldView: UIView {
|
|||
textField.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 17, weight: .semibold), maximumPointSize: 20)
|
||||
textField.textColor = Asset.Colors.Label.primary.color
|
||||
textField.placeholder = L10n.Scene.Profile.Fields.Placeholder.label
|
||||
textField.isEnabled = false
|
||||
return textField
|
||||
}()
|
||||
|
||||
|
|
|
@ -159,13 +159,14 @@ final class ProfileHeaderView: UIView {
|
|||
let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .estimated(44))
|
||||
let group = NSCollectionLayoutGroup.vertical(layoutSize: groupSize, subitems: [item])
|
||||
let section = NSCollectionLayoutSection(group: group)
|
||||
// note: manually set layout inset to workaround header footer layout issue
|
||||
// section.contentInsetsReference = .readableContent
|
||||
section.contentInsetsReference = .readableContent
|
||||
|
||||
let headerFooterSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .estimated(1))
|
||||
let header = NSCollectionLayoutBoundarySupplementaryItem(layoutSize: headerFooterSize, elementKind: UICollectionView.elementKindSectionHeader, alignment: .top)
|
||||
let footer = NSCollectionLayoutBoundarySupplementaryItem(layoutSize: headerFooterSize, elementKind: UICollectionView.elementKindSectionFooter, alignment: .bottom)
|
||||
section.boundarySupplementaryItems = [header, footer]
|
||||
// note: toggle this not take effect
|
||||
// section.supplementariesFollowContentInsets = false
|
||||
|
||||
return UICollectionViewCompositionalLayout(section: section)
|
||||
}
|
||||
|
|
|
@ -59,11 +59,6 @@ extension PublicTimelineViewModel: NSFetchedResultsControllerDelegate {
|
|||
|
||||
var items = [Item]()
|
||||
for (_, status) in indexStatusTuples {
|
||||
let targetStatus = status.reblog ?? status
|
||||
let isStatusTextSensitive: Bool = {
|
||||
guard let spoilerText = targetStatus.spoilerText, !spoilerText.isEmpty else { return false }
|
||||
return true
|
||||
}()
|
||||
let attribute = oldSnapshotAttributeDict[status.objectID] ?? Item.StatusAttribute()
|
||||
items.append(Item.status(objectID: status.objectID, attribute: attribute))
|
||||
if statusIDsWhichHasGap.contains(status.id) {
|
||||
|
|
|
@ -12,8 +12,7 @@ import ActiveLabel
|
|||
import CoreData
|
||||
import CoreDataStack
|
||||
import MastodonSDK
|
||||
import AlamofireImage
|
||||
import Kingfisher
|
||||
|
||||
|
||||
class SettingsViewController: UIViewController, NeedsDependency {
|
||||
|
||||
|
@ -319,36 +318,44 @@ extension SettingsViewController: UITableViewDelegate {
|
|||
|
||||
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
guard let dataSource = viewModel.dataSource else { return }
|
||||
let item = dataSource.itemIdentifier(for: indexPath)
|
||||
guard let item = dataSource.itemIdentifier(for: indexPath) else { return }
|
||||
|
||||
switch item {
|
||||
case .boringZone:
|
||||
guard let url = viewModel.privacyURL else { break }
|
||||
coordinator.present(
|
||||
scene: .safari(url: url),
|
||||
from: self,
|
||||
transition: .safariPresent(animated: true, completion: nil)
|
||||
)
|
||||
case .spicyZone(let link):
|
||||
// clear media cache
|
||||
if link.title == L10n.Scene.Settings.Section.Spicyzone.clear {
|
||||
// clean image cache for AlamofireImage
|
||||
let diskBytes = ImageDownloader.defaultURLCache().currentDiskUsage
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: diskBytes %d", ((#file as NSString).lastPathComponent), #line, #function, diskBytes)
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: clean image cache", ((#file as NSString).lastPathComponent), #line, #function)
|
||||
ImageDownloader.defaultURLCache().removeAllCachedResponses()
|
||||
let cleanedDiskBytes = ImageDownloader.defaultURLCache().currentDiskUsage
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: diskBytes %d", ((#file as NSString).lastPathComponent), #line, #function, cleanedDiskBytes)
|
||||
|
||||
// clean Kingfisher Cache
|
||||
KingfisherManager.shared.cache.clearDiskCache()
|
||||
}
|
||||
// logout
|
||||
if link.title == L10n.Scene.Settings.Section.Spicyzone.signout {
|
||||
case .apperance:
|
||||
// do nothing
|
||||
break
|
||||
case .notification:
|
||||
// do nothing
|
||||
break
|
||||
case .boringZone(let link), .spicyZone(let link):
|
||||
switch link {
|
||||
case .termsOfService, .privacyPolicy:
|
||||
// same URL
|
||||
guard let url = viewModel.privacyURL else { break }
|
||||
coordinator.present(
|
||||
scene: .safari(url: url),
|
||||
from: self,
|
||||
transition: .safariPresent(animated: true, completion: nil)
|
||||
)
|
||||
case .clearMediaCache:
|
||||
context.purgeCache()
|
||||
.receive(on: RunLoop.main)
|
||||
.sink { [weak self] byteCount in
|
||||
guard let self = self else { return }
|
||||
let byteCountformatted = AppContext.byteCountFormatter.string(fromByteCount: Int64(byteCount))
|
||||
let alertController = UIAlertController(
|
||||
title: L10n.Common.Alerts.CleanCache.title,
|
||||
message: L10n.Common.Alerts.CleanCache.message(byteCountformatted),
|
||||
preferredStyle: .alert
|
||||
)
|
||||
let okAction = UIAlertAction(title: L10n.Common.Controls.Actions.ok, style: .default, handler: nil)
|
||||
alertController.addAction(okAction)
|
||||
self.coordinator.present(scene: .alertController(alertController: alertController), from: nil, transition: .alertController(animated: true, completion: nil))
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
case .signOut:
|
||||
alertToSignout()
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -395,7 +395,7 @@ struct MosaicImageView_Previews: PreviewProvider {
|
|||
let images = self.images.prefix(2)
|
||||
let mosaics = view.setupImageViews(count: images.count, maxHeight: 162)
|
||||
for (i, mosiac) in mosaics.enumerated() {
|
||||
let (imageView, blurhashOverlayImageView) = mosiac
|
||||
let (imageView, _) = mosiac
|
||||
imageView.image = images[i]
|
||||
}
|
||||
return view
|
||||
|
@ -407,7 +407,7 @@ struct MosaicImageView_Previews: PreviewProvider {
|
|||
let images = self.images.prefix(3)
|
||||
let mosaics = view.setupImageViews(count: images.count, maxHeight: 162)
|
||||
for (i, mosiac) in mosaics.enumerated() {
|
||||
let (imageView, blurhashOverlayImageView) = mosiac
|
||||
let (imageView, _) = mosiac
|
||||
imageView.image = images[i]
|
||||
}
|
||||
return view
|
||||
|
@ -419,7 +419,7 @@ struct MosaicImageView_Previews: PreviewProvider {
|
|||
let images = self.images.prefix(4)
|
||||
let mosaics = view.setupImageViews(count: images.count, maxHeight: 162)
|
||||
for (i, mosiac) in mosaics.enumerated() {
|
||||
let (imageView, blurhashOverlayImageView) = mosiac
|
||||
let (imageView, _) = mosiac
|
||||
imageView.image = images[i]
|
||||
}
|
||||
return view
|
||||
|
|
|
@ -111,7 +111,7 @@ extension ThreadViewModel.LoadThreadState {
|
|||
override func didEnter(from previousState: GKState?) {
|
||||
super.didEnter(from: previousState)
|
||||
|
||||
guard let viewModel = viewModel, let stateMachine = stateMachine else { return }
|
||||
guard let _ = viewModel, let stateMachine = stateMachine else { return }
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
|
||||
stateMachine.enter(Loading.self)
|
||||
}
|
||||
|
|
|
@ -245,7 +245,7 @@ extension MediaHostToMediaPreviewViewControllerAnimatedTransitioning {
|
|||
|
||||
private func popInteractiveTransition(using transitionContext: UIViewControllerContextTransitioning) {
|
||||
guard let fromVC = transitionContext.viewController(forKey: .from) as? MediaPreviewViewController,
|
||||
let fromView = transitionContext.view(forKey: .from),
|
||||
let _ = transitionContext.view(forKey: .from),
|
||||
let mediaPreviewImageViewController = fromVC.pagingViewConttroller.currentViewController as? MediaPreviewImageViewController,
|
||||
let index = fromVC.pagingViewConttroller.currentIndex else {
|
||||
fatalError()
|
||||
|
|
|
@ -104,7 +104,7 @@ extension Trie {
|
|||
|
||||
var values: NSSet {
|
||||
let valueSet = NSMutableSet(set: self.valueSet)
|
||||
for (key, value) in children {
|
||||
for (_, value) in children {
|
||||
valueSet.addObjects(from: Array(value.values))
|
||||
}
|
||||
|
||||
|
|
|
@ -35,7 +35,7 @@ extension MastodonAttachmentService.UploadState {
|
|||
return true
|
||||
}
|
||||
|
||||
if service?.imageData.value != nil {
|
||||
if service?.file.value != nil {
|
||||
return stateClass == Uploading.self
|
||||
} else {
|
||||
return stateClass == Fail.self
|
||||
|
@ -53,15 +53,8 @@ extension MastodonAttachmentService.UploadState {
|
|||
|
||||
guard let service = service, let stateMachine = stateMachine else { return }
|
||||
guard let authenticationBox = service.authenticationBox else { return }
|
||||
guard let imageData = service.imageData.value else { return }
|
||||
guard let file = service.file.value else { return }
|
||||
|
||||
let file: Mastodon.Query.MediaAttachment = {
|
||||
if imageData.kf.imageFormat == .PNG {
|
||||
return .png(imageData)
|
||||
} else {
|
||||
return .jpeg(imageData)
|
||||
}
|
||||
}()
|
||||
let description = service.description.value
|
||||
let query = Mastodon.API.Media.UploadMeidaQuery(
|
||||
file: file,
|
||||
|
@ -81,6 +74,7 @@ extension MastodonAttachmentService.UploadState {
|
|||
case .failure(let error):
|
||||
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)
|
||||
stateMachine.enter(Fail.self)
|
||||
case .finished:
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: upload attachment success", ((#file as NSString).lastPathComponent), #line, #function)
|
||||
break
|
||||
|
|
|
@ -5,11 +5,13 @@
|
|||
// Created by MainasuK Cirno on 2021-3-17.
|
||||
//
|
||||
|
||||
import os.log
|
||||
import UIKit
|
||||
import Combine
|
||||
import PhotosUI
|
||||
import Kingfisher
|
||||
import GameplayKit
|
||||
import MobileCoreServices
|
||||
import MastodonSDK
|
||||
|
||||
protocol MastodonAttachmentServiceDelegate: AnyObject {
|
||||
|
@ -22,16 +24,16 @@ final class MastodonAttachmentService {
|
|||
weak var delegate: MastodonAttachmentServiceDelegate?
|
||||
|
||||
let identifier = UUID()
|
||||
|
||||
|
||||
// input
|
||||
let context: AppContext
|
||||
var authenticationBox: AuthenticationService.MastodonAuthenticationBox?
|
||||
let file = CurrentValueSubject<Mastodon.Query.MediaAttachment?, Never>(nil)
|
||||
let description = CurrentValueSubject<String?, Never>(nil)
|
||||
|
||||
// output
|
||||
// TODO: handle video/GIF/Audio data
|
||||
let imageData = CurrentValueSubject<Data?, Never>(nil)
|
||||
let thumbnailImage = CurrentValueSubject<UIImage?, Never>(nil)
|
||||
let attachment = CurrentValueSubject<Mastodon.Entity.Attachment?, Never>(nil)
|
||||
let description = CurrentValueSubject<String?, Never>(nil)
|
||||
let error = CurrentValueSubject<Error?, Never>(nil)
|
||||
|
||||
private(set) lazy var uploadStateMachine: GKStateMachine = {
|
||||
|
@ -58,7 +60,16 @@ final class MastodonAttachmentService {
|
|||
|
||||
setupServiceObserver()
|
||||
|
||||
PHPickerResultLoader.loadImageData(from: pickerResult)
|
||||
Just(pickerResult)
|
||||
.flatMap { result -> AnyPublisher<Mastodon.Query.MediaAttachment?, Error> in
|
||||
if result.itemProvider.hasRepresentationConforming(toTypeIdentifier: UTType.image.identifier, fileOptions: []) {
|
||||
return PHPickerResultLoader.loadImageData(from: result).eraseToAnyPublisher()
|
||||
}
|
||||
if result.itemProvider.hasRepresentationConforming(toTypeIdentifier: UTType.movie.identifier, fileOptions: []) {
|
||||
return PHPickerResultLoader.loadVideoData(from: result).eraseToAnyPublisher()
|
||||
}
|
||||
return Fail(error: AttachmentError.invalidAttachmentType).eraseToAnyPublisher()
|
||||
}
|
||||
.sink { [weak self] completion in
|
||||
guard let self = self else { return }
|
||||
switch completion {
|
||||
|
@ -68,9 +79,9 @@ final class MastodonAttachmentService {
|
|||
case .finished:
|
||||
break
|
||||
}
|
||||
} receiveValue: { [weak self] imageData in
|
||||
} receiveValue: { [weak self] file in
|
||||
guard let self = self else { return }
|
||||
self.imageData.value = imageData
|
||||
self.file.value = file
|
||||
self.uploadStateMachine.enter(UploadState.Initial.self)
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
|
@ -87,13 +98,13 @@ final class MastodonAttachmentService {
|
|||
|
||||
setupServiceObserver()
|
||||
|
||||
imageData.value = image.jpegData(compressionQuality: 0.75)
|
||||
file.value = .jpeg(image.jpegData(compressionQuality: 0.75))
|
||||
uploadStateMachine.enter(UploadState.Initial.self)
|
||||
}
|
||||
|
||||
init(
|
||||
context: AppContext,
|
||||
imageData: Data,
|
||||
documentURL: URL,
|
||||
initalAuthenticationBox: AuthenticationService.MastodonAuthenticationBox?
|
||||
) {
|
||||
self.context = context
|
||||
|
@ -102,7 +113,26 @@ final class MastodonAttachmentService {
|
|||
|
||||
setupServiceObserver()
|
||||
|
||||
self.imageData.value = imageData
|
||||
Just(documentURL)
|
||||
.flatMap { documentURL -> AnyPublisher<Mastodon.Query.MediaAttachment, Error> in
|
||||
return MastodonAttachmentService.loadAttachment(url: documentURL)
|
||||
}
|
||||
.sink { [weak self] completion in
|
||||
guard let self = self else { return }
|
||||
switch completion {
|
||||
case .failure(let error):
|
||||
self.error.value = error
|
||||
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)
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
|
||||
uploadStateMachine.enter(UploadState.Initial.self)
|
||||
}
|
||||
|
||||
|
@ -113,6 +143,49 @@ final class MastodonAttachmentService {
|
|||
self.delegate?.mastodonAttachmentService(self, uploadStateDidChange: state)
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
|
||||
|
||||
file
|
||||
.map { file -> UIImage? in
|
||||
guard let file = file else {
|
||||
return nil
|
||||
}
|
||||
|
||||
switch file {
|
||||
case .jpeg(let data), .png(let data):
|
||||
return data.flatMap { UIImage(data: $0) }
|
||||
case .gif:
|
||||
// TODO:
|
||||
return nil
|
||||
case .other(let url, _, _):
|
||||
guard let url = url, FileManager.default.fileExists(atPath: url.path) else { return nil }
|
||||
let asset = AVURLAsset(url: url)
|
||||
let assetImageGenerator = AVAssetImageGenerator(asset: asset)
|
||||
assetImageGenerator.appliesPreferredTrackTransform = true // fix orientation
|
||||
do {
|
||||
let cgImage = try assetImageGenerator.copyCGImage(at: CMTimeMake(value: 0, timescale: 1), actualTime: nil)
|
||||
let image = UIImage(cgImage: cgImage)
|
||||
return image
|
||||
} catch {
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: thumbnail generate fail: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
.assign(to: \.value, on: thumbnailImage)
|
||||
.store(in: &disposeBag)
|
||||
}
|
||||
|
||||
deinit {
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension MastodonAttachmentService {
|
||||
enum AttachmentError: Error {
|
||||
case invalidAttachmentType
|
||||
case attachmentTooLarge
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -136,3 +209,64 @@ extension MastodonAttachmentService: Equatable, Hashable {
|
|||
}
|
||||
|
||||
}
|
||||
|
||||
extension MastodonAttachmentService {
|
||||
|
||||
private static func createWorkingQueue() -> DispatchQueue {
|
||||
return DispatchQueue(label: "org.joinmastodon.Mastodon.MastodonAttachmentService.\(UUID().uuidString)")
|
||||
}
|
||||
|
||||
static func loadAttachment(url: URL) -> AnyPublisher<Mastodon.Query.MediaAttachment, Error> {
|
||||
guard let uti = UTType(filenameExtension: url.pathExtension) else {
|
||||
return Fail(error: AttachmentError.invalidAttachmentType).eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
if uti.conforms(to: .image) {
|
||||
return loadImageAttachment(url: url)
|
||||
} else if uti.conforms(to: .movie) {
|
||||
return loadVideoAttachment(url: url)
|
||||
} else {
|
||||
return Fail(error: AttachmentError.invalidAttachmentType).eraseToAnyPublisher()
|
||||
}
|
||||
}
|
||||
|
||||
static func loadImageAttachment(url: URL) -> AnyPublisher<Mastodon.Query.MediaAttachment, Error> {
|
||||
Future<Mastodon.Query.MediaAttachment, Error> { promise in
|
||||
createWorkingQueue().async {
|
||||
do {
|
||||
guard url.startAccessingSecurityScopedResource() else { return }
|
||||
defer { url.stopAccessingSecurityScopedResource() }
|
||||
let imageData = try Data(contentsOf: url)
|
||||
promise(.success(.jpeg(imageData)))
|
||||
} catch {
|
||||
os_log("%{public}s[%{public}ld], %{public}s: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription)
|
||||
promise(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
static func loadVideoAttachment(url: URL) -> AnyPublisher<Mastodon.Query.MediaAttachment, Error> {
|
||||
Future<Mastodon.Query.MediaAttachment, Error> { promise in
|
||||
createWorkingQueue().async {
|
||||
guard url.startAccessingSecurityScopedResource() else { return }
|
||||
defer { url.stopAccessingSecurityScopedResource() }
|
||||
|
||||
let fileName = UUID().uuidString
|
||||
let tempDirectoryURL = FileManager.default.temporaryDirectory
|
||||
let fileURL = tempDirectoryURL.appendingPathComponent(fileName).appendingPathExtension(url.pathExtension)
|
||||
do {
|
||||
try FileManager.default.createDirectory(at: tempDirectoryURL, withIntermediateDirectories: true, attributes: nil)
|
||||
try FileManager.default.copyItem(at: url, to: fileURL)
|
||||
let file = Mastodon.Query.MediaAttachment.other(fileURL, fileExtension: fileURL.pathExtension, mimeType: UTType.movie.preferredMIMEType ?? "video/mp4")
|
||||
promise(.success(file))
|
||||
} catch {
|
||||
promise(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -10,6 +10,8 @@ import UIKit
|
|||
import Combine
|
||||
import CoreData
|
||||
import CoreDataStack
|
||||
import AlamofireImage
|
||||
import Kingfisher
|
||||
|
||||
class AppContext: ObservableObject {
|
||||
|
||||
|
@ -99,3 +101,107 @@ class AppContext: ObservableObject {
|
|||
}
|
||||
|
||||
}
|
||||
|
||||
extension AppContext {
|
||||
|
||||
typealias ByteCount = Int
|
||||
|
||||
static let byteCountFormatter: ByteCountFormatter = {
|
||||
let formatter = ByteCountFormatter()
|
||||
return formatter
|
||||
}()
|
||||
|
||||
private static let purgeCacheWorkingQueue = DispatchQueue(label: "org.joinmastodon.Mastodon.AppContext.purgeCacheWorkingQueue")
|
||||
|
||||
func purgeCache() -> AnyPublisher<ByteCount, Never> {
|
||||
Publishers.MergeMany([
|
||||
AppContext.purgeAlamofireImageCache(),
|
||||
AppContext.purgeKingfisherCache(),
|
||||
AppContext.purgeTemporaryDirectory(),
|
||||
])
|
||||
.reduce(0, +)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
private static func purgeAlamofireImageCache() -> AnyPublisher<ByteCount, Never> {
|
||||
Future<ByteCount, Never> { promise in
|
||||
AppContext.purgeCacheWorkingQueue.async {
|
||||
// clean image cache for AlamofireImage
|
||||
let diskBytes = ImageDownloader.defaultURLCache().currentDiskUsage
|
||||
ImageDownloader.defaultURLCache().removeAllCachedResponses()
|
||||
let currentDiskBytes = ImageDownloader.defaultURLCache().currentDiskUsage
|
||||
let purgedDiskBytes = max(0, diskBytes - currentDiskBytes)
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: purge AlamofireImage cache bytes: %ld -> %ld (%ld)", ((#file as NSString).lastPathComponent), #line, #function, diskBytes, currentDiskBytes, purgedDiskBytes)
|
||||
promise(.success(purgedDiskBytes))
|
||||
}
|
||||
}
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
private static func purgeKingfisherCache() -> AnyPublisher<ByteCount, Never> {
|
||||
Future<ByteCount, Never> { promise in
|
||||
KingfisherManager.shared.cache.calculateDiskStorageSize { result in
|
||||
switch result {
|
||||
case .success(let diskBytes):
|
||||
KingfisherManager.shared.cache.clearCache()
|
||||
KingfisherManager.shared.cache.calculateDiskStorageSize { currentResult in
|
||||
switch currentResult {
|
||||
case .success(let currentDiskBytes):
|
||||
let purgedDiskBytes = max(0, Int(diskBytes) - Int(currentDiskBytes))
|
||||
promise(.success(purgedDiskBytes))
|
||||
case .failure:
|
||||
promise(.success(0))
|
||||
}
|
||||
}
|
||||
case .failure:
|
||||
promise(.success(0))
|
||||
}
|
||||
}
|
||||
}
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
private static func purgeTemporaryDirectory() -> AnyPublisher<ByteCount, Never> {
|
||||
Future<ByteCount, Never> { promise in
|
||||
AppContext.purgeCacheWorkingQueue.async {
|
||||
let fileManager = FileManager.default
|
||||
let temporaryDirectoryURL = fileManager.temporaryDirectory
|
||||
|
||||
let resourceKeys = Set<URLResourceKey>([.fileSizeKey, .isDirectoryKey])
|
||||
guard let directoryEnumerator = fileManager.enumerator(
|
||||
at: temporaryDirectoryURL,
|
||||
includingPropertiesForKeys: Array(resourceKeys),
|
||||
options: .skipsHiddenFiles
|
||||
) else {
|
||||
promise(.success(0))
|
||||
return
|
||||
}
|
||||
|
||||
var fileURLs: [URL] = []
|
||||
var totalFileSizeInBytes = 0
|
||||
for case let fileURL as URL in directoryEnumerator {
|
||||
guard let resourceValues = try? fileURL.resourceValues(forKeys: resourceKeys),
|
||||
let isDirectory = resourceValues.isDirectory else {
|
||||
continue
|
||||
}
|
||||
|
||||
guard !isDirectory else {
|
||||
continue
|
||||
}
|
||||
fileURLs.append(fileURL)
|
||||
totalFileSizeInBytes += resourceValues.fileSize ?? 0
|
||||
}
|
||||
|
||||
for fileURL in fileURLs {
|
||||
try? fileManager.removeItem(at: fileURL)
|
||||
}
|
||||
|
||||
promise(.success(totalFileSizeInBytes))
|
||||
}
|
||||
}
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
//
|
||||
// os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: purge temporary directory success", ((#file as NSString).lastPathComponent), #line, #function)
|
||||
// }
|
||||
}
|
||||
|
|
|
@ -10,12 +10,13 @@ import Foundation
|
|||
import Combine
|
||||
import MobileCoreServices
|
||||
import PhotosUI
|
||||
import MastodonSDK
|
||||
|
||||
// load image with low memory usage
|
||||
// Refs: https://christianselig.com/2020/09/phpickerviewcontroller-efficiently/
|
||||
enum PHPickerResultLoader {
|
||||
|
||||
static func loadImageData(from result: PHPickerResult) -> Future<Data?, Error> {
|
||||
static func loadImageData(from result: PHPickerResult) -> Future<Mastodon.Query.MediaAttachment?, Error> {
|
||||
Future { promise in
|
||||
result.itemProvider.loadFileRepresentation(forTypeIdentifier: UTType.image.identifier) { url, error in
|
||||
if let error = error {
|
||||
|
@ -64,7 +65,36 @@ enum PHPickerResultLoader {
|
|||
let dataSize = ByteCountFormatter.string(fromByteCount: Int64(data.length), countStyle: .memory)
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: load image %s", ((#file as NSString).lastPathComponent), #line, #function, dataSize)
|
||||
|
||||
promise(.success(data as Data))
|
||||
let file = Mastodon.Query.MediaAttachment.jpeg(data as Data)
|
||||
promise(.success(file))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static func loadVideoData(from result: PHPickerResult) -> Future<Mastodon.Query.MediaAttachment?, Error> {
|
||||
Future { promise in
|
||||
result.itemProvider.loadFileRepresentation(forTypeIdentifier: UTType.movie.identifier) { url, error in
|
||||
if let error = error {
|
||||
promise(.failure(error))
|
||||
return
|
||||
}
|
||||
|
||||
guard let url = url else {
|
||||
promise(.success(nil))
|
||||
return
|
||||
}
|
||||
|
||||
let fileName = UUID().uuidString
|
||||
let tempDirectoryURL = FileManager.default.temporaryDirectory
|
||||
let fileURL = tempDirectoryURL.appendingPathComponent(fileName).appendingPathExtension(url.pathExtension)
|
||||
do {
|
||||
try FileManager.default.createDirectory(at: tempDirectoryURL, withIntermediateDirectories: true, attributes: nil)
|
||||
try FileManager.default.copyItem(at: url, to: fileURL)
|
||||
let file = Mastodon.Query.MediaAttachment.other(fileURL, fileExtension: fileURL.pathExtension, mimeType: UTType.movie.preferredMIMEType ?? "video/mp4")
|
||||
promise(.success(file))
|
||||
} catch {
|
||||
promise(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -217,9 +217,15 @@ extension Mastodon.API.Account {
|
|||
source.sensitive.flatMap { data.append(Data.multipart(key: "source[privacy]", value: $0)) }
|
||||
source.language.flatMap { data.append(Data.multipart(key: "source[privacy]", value: $0)) }
|
||||
}
|
||||
for (i, fieldsAttribute) in (fieldsAttributes ?? []).enumerated() {
|
||||
data.append(Data.multipart(key: "fields_attributes[\(i)][name]", value: fieldsAttribute.name))
|
||||
data.append(Data.multipart(key: "fields_attributes[\(i)][value]", value: fieldsAttribute.value))
|
||||
if let fieldsAttributes = fieldsAttributes {
|
||||
if fieldsAttributes.isEmpty {
|
||||
data.append(Data.multipart(key: "fields_attributes[]", value: ""))
|
||||
} else {
|
||||
for (i, fieldsAttribute) in fieldsAttributes.enumerated() {
|
||||
data.append(Data.multipart(key: "fields_attributes[\(i)][name]", value: fieldsAttribute.name))
|
||||
data.append(Data.multipart(key: "fields_attributes[\(i)][value]", value: fieldsAttribute.value))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data.append(Data.multipartEnd())
|
||||
|
|
|
@ -42,11 +42,17 @@ extension Mastodon.API.Media {
|
|||
authorization: authorization
|
||||
)
|
||||
request.timeoutInterval = 180 // should > 200 Kb/s for 40 MiB media attachment
|
||||
let serialStream = query.serialStream
|
||||
request.httpBodyStream = serialStream.boundStreams.input
|
||||
return session.dataTaskPublisher(for: request)
|
||||
.tryMap { data, response in
|
||||
let value = try Mastodon.API.decode(type: Mastodon.Entity.Attachment.self, from: data, response: response)
|
||||
return Mastodon.Response.Content(value: value, response: response)
|
||||
}
|
||||
.handleEvents(receiveCancel: {
|
||||
// retain and handle cancel task
|
||||
serialStream.boundStreams.output.close()
|
||||
})
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
|
@ -73,15 +79,30 @@ extension Mastodon.API.Media {
|
|||
}
|
||||
|
||||
var body: Data? {
|
||||
var data = Data()
|
||||
|
||||
file.flatMap { data.append(Data.multipart(key: "file", value: $0)) }
|
||||
thumbnail.flatMap { data.append(Data.multipart(key: "thumbnail", value: $0)) }
|
||||
description.flatMap { data.append(Data.multipart(key: "description", value: $0)) }
|
||||
focus.flatMap { data.append(Data.multipart(key: "focus", value: $0)) }
|
||||
// using stream data
|
||||
return nil
|
||||
}
|
||||
|
||||
var serialStream: SerialStream {
|
||||
var streams: [InputStream] = []
|
||||
|
||||
data.append(Data.multipartEnd())
|
||||
return data
|
||||
file.flatMap { value in
|
||||
streams.append(InputStream(data: Data.multipart(key: "file", value: value)))
|
||||
value.multipartStreamValue.flatMap { streams.append($0) }
|
||||
}
|
||||
thumbnail.flatMap { value in
|
||||
streams.append(InputStream(data: Data.multipart(key: "thumbnail", value: value)))
|
||||
value.multipartStreamValue.flatMap { streams.append($0) }
|
||||
}
|
||||
description.flatMap { value in
|
||||
streams.append(InputStream(data: Data.multipart(key: "description", value: value)))
|
||||
}
|
||||
focus.flatMap { value in
|
||||
streams.append(InputStream(data: Data.multipart(key: "focus", value: value)))
|
||||
}
|
||||
streams.append(InputStream(data: Data.multipartEnd()))
|
||||
|
||||
return SerialStream(streams: streams)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -129,8 +150,45 @@ extension Mastodon.API.Media {
|
|||
}
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
public typealias UpdateMediaQuery = UploadMeidaQuery
|
||||
|
||||
public struct UpdateMediaQuery: PutQuery {
|
||||
|
||||
public let file: Mastodon.Query.MediaAttachment?
|
||||
public let thumbnail: Mastodon.Query.MediaAttachment?
|
||||
public let description: String?
|
||||
public let focus: String?
|
||||
|
||||
public init(
|
||||
file: Mastodon.Query.MediaAttachment?,
|
||||
thumbnail: Mastodon.Query.MediaAttachment?,
|
||||
description: String?,
|
||||
focus: String?
|
||||
) {
|
||||
self.file = file
|
||||
self.thumbnail = thumbnail
|
||||
self.description = description
|
||||
self.focus = focus
|
||||
}
|
||||
|
||||
var contentType: String? {
|
||||
return Self.multipartContentType()
|
||||
}
|
||||
|
||||
var queryItems: [URLQueryItem]? {
|
||||
return nil
|
||||
}
|
||||
|
||||
var body: Data? {
|
||||
var data = Data()
|
||||
|
||||
// not modify uploaded binary data
|
||||
description.flatMap { data.append(Data.multipart(key: "description", value: $0)) }
|
||||
focus.flatMap { data.append(Data.multipart(key: "focus", value: $0)) }
|
||||
|
||||
data.append(Data.multipartEnd())
|
||||
return data
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
|
|
@ -26,7 +26,12 @@ extension Data {
|
|||
data.append("Content-Type: \(contentType)\r\n".data(using: .utf8)!)
|
||||
}
|
||||
data.append("\r\n".data(using: .utf8)!)
|
||||
data.append(value.multipartValue)
|
||||
if value.multipartStreamValue == nil {
|
||||
data.append(value.multipartValue)
|
||||
} else {
|
||||
// needs append stream multipart value outside
|
||||
// seealso: SerialStream
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
|
|
|
@ -16,21 +16,22 @@ extension Mastodon.Query {
|
|||
/// PNG (Portable Network Graphics) image
|
||||
case png(Data?)
|
||||
/// Other media file
|
||||
case other(Data?, fileExtension: String, mimeType: String)
|
||||
/// e.g video
|
||||
case other(URL?, fileExtension: String, mimeType: String)
|
||||
}
|
||||
}
|
||||
|
||||
extension Mastodon.Query.MediaAttachment {
|
||||
var data: Data? {
|
||||
public var data: Data? {
|
||||
switch self {
|
||||
case .jpeg(let data): return data
|
||||
case .gif(let data): return data
|
||||
case .png(let data): return data
|
||||
case .other(let data, _, _): return data
|
||||
case .other: return nil
|
||||
}
|
||||
}
|
||||
|
||||
var fileName: String {
|
||||
public var fileName: String {
|
||||
let name = UUID().uuidString
|
||||
switch self {
|
||||
case .jpeg: return "\(name).jpg"
|
||||
|
@ -40,7 +41,7 @@ extension Mastodon.Query.MediaAttachment {
|
|||
}
|
||||
}
|
||||
|
||||
var mimeType: String {
|
||||
public var mimeType: String {
|
||||
switch self {
|
||||
case .jpeg: return "image/jpg"
|
||||
case .gif: return "image/gif"
|
||||
|
@ -56,6 +57,14 @@ extension Mastodon.Query.MediaAttachment {
|
|||
|
||||
extension Mastodon.Query.MediaAttachment: MultipartFormValue {
|
||||
var multipartValue: Data { return data ?? Data() }
|
||||
var multipartStreamValue: InputStream? {
|
||||
switch self {
|
||||
case .other(let url, _, _):
|
||||
return url.flatMap { InputStream(url: $0) }
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
var multipartContentType: String? { return mimeType }
|
||||
var multipartFilename: String? { return fileName }
|
||||
}
|
||||
|
|
|
@ -13,10 +13,15 @@ enum Multipart {
|
|||
|
||||
protocol MultipartFormValue {
|
||||
var multipartValue: Data { get }
|
||||
var multipartStreamValue: InputStream? { get }
|
||||
var multipartContentType: String? { get }
|
||||
var multipartFilename: String? { get }
|
||||
}
|
||||
|
||||
extension MultipartFormValue {
|
||||
var multipartStreamValue: InputStream? { nil }
|
||||
}
|
||||
|
||||
extension Bool: MultipartFormValue {
|
||||
var multipartValue: Data {
|
||||
switch self {
|
||||
|
|
|
@ -0,0 +1,147 @@
|
|||
//
|
||||
// SerialStream.swift
|
||||
//
|
||||
//
|
||||
// Created by MainasuK Cirno on 2021-5-28.
|
||||
//
|
||||
|
||||
import os.log
|
||||
import Foundation
|
||||
import Combine
|
||||
|
||||
// ref:
|
||||
// - https://developer.apple.com/documentation/foundation/url_loading_system/uploading_streams_of_data#3037342
|
||||
// - https://forums.swift.org/t/extension-write-to-outputstream/42817/4
|
||||
// - https://gist.github.com/khanlou/b5e07f963bedcb6e0fcc5387b46991c3
|
||||
|
||||
final class SerialStream: NSObject {
|
||||
var writingTimerSubscriber: AnyCancellable?
|
||||
|
||||
// serial stream source
|
||||
private var streams: [InputStream]
|
||||
private var currentStreamIndex = 0
|
||||
|
||||
private static let bufferSize = 5 * 1024 * 1024 // 5MiB
|
||||
|
||||
private var buffer: UnsafeMutablePointer<UInt8>
|
||||
private var canWrite = false
|
||||
|
||||
private let workingQueue = DispatchQueue(label: "org.joinmastodon.Mastodon.SerialStream.\(UUID().uuidString)")
|
||||
|
||||
// bound pair stream
|
||||
private(set) lazy var boundStreams: Streams = {
|
||||
var inputStream: InputStream?
|
||||
var outputStream: OutputStream?
|
||||
Stream.getBoundStreams(withBufferSize: SerialStream.bufferSize, inputStream: &inputStream, outputStream: &outputStream)
|
||||
guard let input = inputStream, let output = outputStream else {
|
||||
fatalError()
|
||||
}
|
||||
|
||||
output.delegate = self
|
||||
output.schedule(in: .current, forMode: .default)
|
||||
output.open()
|
||||
|
||||
return Streams(input: input, output: output)
|
||||
}()
|
||||
|
||||
init(streams: [InputStream]) {
|
||||
self.streams = streams
|
||||
self.buffer = UnsafeMutablePointer<UInt8>.allocate(capacity: SerialStream.bufferSize)
|
||||
self.buffer.initialize(repeating: 0, count: SerialStream.bufferSize)
|
||||
super.init()
|
||||
|
||||
// Stream worker
|
||||
writingTimerSubscriber = Timer.publish(every: 0.5, on: .current, in: .default)
|
||||
.autoconnect()
|
||||
.receive(on: workingQueue)
|
||||
.sink { [weak self] timer in
|
||||
guard let self = self else { return }
|
||||
guard self.canWrite else { return }
|
||||
os_log(.debug, "%{public}s[%{public}ld], %{public}s: writing…", ((#file as NSString).lastPathComponent), #line, #function)
|
||||
|
||||
guard self.currentStreamIndex < self.streams.count else {
|
||||
self.boundStreams.output.close()
|
||||
self.writingTimerSubscriber = nil // cancel timer after task completed
|
||||
return
|
||||
}
|
||||
|
||||
var readBytesCount = 0
|
||||
defer {
|
||||
var baseAddress = 0
|
||||
var remainsBytes = readBytesCount
|
||||
while remainsBytes > 0 {
|
||||
let result = self.boundStreams.output.write(&self.buffer[baseAddress], maxLength: remainsBytes)
|
||||
baseAddress += result
|
||||
remainsBytes -= result
|
||||
os_log(.debug, "%{public}s[%{public}ld], %{public}s: write %ld/%ld bytes. write result: %ld", ((#file as NSString).lastPathComponent), #line, #function, baseAddress, readBytesCount, result)
|
||||
}
|
||||
}
|
||||
|
||||
while readBytesCount < SerialStream.bufferSize {
|
||||
// close when no more source streams
|
||||
guard self.currentStreamIndex < self.streams.count else {
|
||||
break
|
||||
}
|
||||
|
||||
let inputStream = self.streams[self.currentStreamIndex]
|
||||
// open input if needs
|
||||
if inputStream.streamStatus != .open {
|
||||
inputStream.open()
|
||||
}
|
||||
// read next source stream when current drain
|
||||
guard inputStream.hasBytesAvailable else {
|
||||
self.currentStreamIndex += 1
|
||||
continue
|
||||
}
|
||||
|
||||
let reaminsCount = SerialStream.bufferSize - readBytesCount
|
||||
let readCount = inputStream.read(&self.buffer[readBytesCount], maxLength: reaminsCount)
|
||||
os_log(.debug, "%{public}s[%{public}ld], %{public}s: read source %ld bytes", ((#file as NSString).lastPathComponent), #line, #function, readCount)
|
||||
|
||||
switch readCount {
|
||||
case 0:
|
||||
self.currentStreamIndex += 1
|
||||
continue
|
||||
case -1:
|
||||
self.boundStreams.output.close()
|
||||
return
|
||||
default:
|
||||
self.canWrite = false
|
||||
readBytesCount += readCount
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
deinit {
|
||||
os_log(.debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension SerialStream {
|
||||
struct Streams {
|
||||
let input: InputStream
|
||||
let output: OutputStream
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - StreamDelegate
|
||||
extension SerialStream: StreamDelegate {
|
||||
func stream(_ aStream: Stream, handle eventCode: Stream.Event) {
|
||||
os_log(.debug, "%{public}s[%{public}ld], %{public}s: eventCode %s", ((#file as NSString).lastPathComponent), #line, #function, String(eventCode.rawValue))
|
||||
|
||||
guard aStream == boundStreams.output else {
|
||||
return
|
||||
}
|
||||
|
||||
if eventCode.contains(.hasSpaceAvailable) {
|
||||
canWrite = true
|
||||
}
|
||||
|
||||
if eventCode.contains(.errorOccurred) {
|
||||
// Close the streams and alert the user that the upload failed.
|
||||
boundStreams.output.close()
|
||||
}
|
||||
}
|
||||
}
|
17
Podfile
17
Podfile
|
@ -13,6 +13,9 @@ target 'Mastodon' do
|
|||
pod 'SwiftGen', '~> 6.4.0'
|
||||
pod 'DateToolsSwift', '~> 5.0.0'
|
||||
pod 'Kanna', '~> 5.2.2'
|
||||
|
||||
# DEBUG
|
||||
pod 'FLEX', '~> 4.4.0', :configurations => ['Debug']
|
||||
|
||||
target 'MastodonTests' do
|
||||
inherit! :search_paths
|
||||
|
@ -23,14 +26,16 @@ target 'Mastodon' do
|
|||
# Pods for testing
|
||||
end
|
||||
|
||||
target 'NotificationService' do
|
||||
end
|
||||
|
||||
end
|
||||
|
||||
target 'AppShared' do
|
||||
|
||||
end
|
||||
target 'NotificationService' do
|
||||
# Comment the next line if you don't want to use dynamic frameworks
|
||||
use_frameworks!
|
||||
end
|
||||
|
||||
target 'AppShared' do
|
||||
# Comment the next line if you don't want to use dynamic frameworks
|
||||
use_frameworks!
|
||||
end
|
||||
|
||||
plugin 'cocoapods-keys', {
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
PODS:
|
||||
- DateToolsSwift (5.0.0)
|
||||
- FLEX (4.4.1)
|
||||
- Kanna (5.2.4)
|
||||
- Keys (1.0.1)
|
||||
- SwiftGen (6.4.0)
|
||||
|
@ -7,6 +8,7 @@ PODS:
|
|||
|
||||
DEPENDENCIES:
|
||||
- DateToolsSwift (~> 5.0.0)
|
||||
- FLEX (~> 4.4.0)
|
||||
- Kanna (~> 5.2.2)
|
||||
- Keys (from `Pods/CocoaPodsKeys`)
|
||||
- SwiftGen (~> 6.4.0)
|
||||
|
@ -15,6 +17,7 @@ DEPENDENCIES:
|
|||
SPEC REPOS:
|
||||
trunk:
|
||||
- DateToolsSwift
|
||||
- FLEX
|
||||
- Kanna
|
||||
- SwiftGen
|
||||
- "UITextField+Shake"
|
||||
|
@ -25,11 +28,12 @@ EXTERNAL SOURCES:
|
|||
|
||||
SPEC CHECKSUMS:
|
||||
DateToolsSwift: 4207ada6ad615d8dc076323d27037c94916dbfa6
|
||||
FLEX: 7ca2c8cd3a435ff501ff6d2f2141e9bdc934eaab
|
||||
Kanna: b9d00d7c11428308c7f95e1f1f84b8205f567a8f
|
||||
Keys: a576f4c9c1c641ca913a959a9c62ed3f215a8de9
|
||||
SwiftGen: 67860cc7c3cfc2ed25b9b74cfd55495fc89f9108
|
||||
"UITextField+Shake": 298ac5a0f239d731bdab999b19b628c956ca0ac3
|
||||
|
||||
PODFILE CHECKSUM: a8dbae22e6e0bfb84f7db59aef1aa1716793d287
|
||||
PODFILE CHECKSUM: 0daad1778e56099e7a7e7ebe3d292d20051840fa
|
||||
|
||||
COCOAPODS: 1.10.1
|
||||
|
|
Loading…
Reference in New Issue