Merge pull request #142 from tootsuite/feature/post-video

Add publish video support
This commit is contained in:
CMK 2021-06-01 14:39:38 +08:00 committed by GitHub
commit 356d41672e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
43 changed files with 847 additions and 191 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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";

View File

@ -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";

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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
View File

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

View File

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